Architecture
RNCopilot follows a layered architecture designed for scalability, testability, and developer ergonomics. Every layer has a single responsibility, and the boundaries between layers are enforced by convention.
Tech Stack
| Layer | Technology | Version | Purpose |
|---|---|---|---|
| Framework | React Native + Expo | 0.83.2 / SDK 55 | Cross-platform mobile runtime |
| Routing | expo-router | File-based | Typed, file-system routing |
| Language | TypeScript | 5.9 (strict) | Static typing with path aliases |
| Styling | react-native-unistyles | 3.x | Theme-aware StyleSheet with variants |
| Server State | @tanstack/react-query | — | Caching, refetching, mutations |
| Client State | Zustand | — | Lightweight stores with selectors |
| Backend | Supabase | — | Auth, database, storage (optional) |
| API Client | Axios | — | Interceptors for auth and errors |
| i18n | react-i18next | — | EN/AR with RTL support |
| Storage | react-native-mmkv | — | Fast, synchronous key-value persistence |
| Forms | react-hook-form + Zod | — | Schema-first validation |
| Testing | Jest 30 + jest-expo | — | Unit and integration tests |
| Linting | ESLint 9 | Flat config | Code quality enforcement |
Provider Hierarchy
The root layout (app/_layout.tsx) composes providers in a specific order:
<GestureHandlerRootView>
<ErrorBoundary>
<QueryProvider>
<BottomSheetModalProvider>
<AppContent /> {/* calls useAuthInit() */}
</BottomSheetModalProvider>
</QueryProvider>
</ErrorBoundary>
</GestureHandlerRootView>Why This Order Matters
- GestureHandlerRootView must wrap the entire tree for gesture-based components (bottom sheets, swipe actions).
- ErrorBoundary catches unhandled errors in the entire app, including provider initialization failures.
- QueryProvider sets up React Query with an MMKV-based persister, so cached data survives app restarts.
- BottomSheetModalProvider enables bottom sheet modals anywhere in the tree.
- AppContent initializes auth state via
useAuthInit()and renders the navigation stack.
Graceful Supabase Initialization
The app boots and works without Supabase configured. The integration file checks for environment variables at startup:
// src/integrations/supabase.ts
if (env.supabaseUrl && env.supabaseAnonKey && isValidUrl(env.supabaseUrl)) {
supabase = createClient(env.supabaseUrl, env.supabaseAnonKey, {
auth: {
storage: supabaseStorageAdapter, // MMKV-backed
persistSession: true,
autoRefreshToken: true,
},
});
} else {
// Logs a dev warning and assigns null stub
supabase = null as unknown as SupabaseClient;
}When Supabase is not configured, any call to supabase.* will throw. Guard Supabase-dependent code with a null check or configure your .env file before using auth or database features.
Authentication via Zustand
Auth state is managed by a Zustand store, not a React Context provider. This design gives you:
- Non-React access — Axios interceptors read the session token via
useAuthStore.getState().sessionwithout needing a React component tree. - Selector-based subscriptions — Components only re-render when the specific slice of auth state they read changes.
- Simplified testing — Store state can be set directly in tests without wrapping in providers.
// Reading auth state in a component
const user = useAuthStore((s) => s.user);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
// Reading auth state outside React (e.g., Axios interceptor)
const session = useAuthStore.getState().session;The useAuthInit() hook, called in the root layout, handles initialization and cleanup:
function useAuthInit() {
const initialize = useAuthStore((s) => s.initialize);
const cleanup = useAuthStore((s) => s.cleanup);
useEffect(() => {
initialize();
return () => cleanup();
}, [initialize, cleanup]);
}ErrorBoundary
The ErrorBoundary component wraps the entire app at the root level. It catches unhandled JavaScript errors and renders a recovery UI instead of a white screen:
<ErrorBoundary>
<QueryProvider>
{/* ... entire app ... */}
</QueryProvider>
</ErrorBoundary>You can also wrap individual sections of your app with ErrorBoundary for more granular error isolation:
<ErrorBoundary>
<RiskyFeatureComponent />
</ErrorBoundary>API Client with Interceptors
The Axios client (src/services/api/client.ts) provides two interceptors out of the box:
Request Interceptor — Auto Bearer Token
api.interceptors.request.use((config) => {
const session = useAuthStore.getState().session;
if (session?.access_token) {
config.headers.Authorization = `Bearer ${session.access_token}`;
}
return config;
});Response Interceptor — Error Normalization + Auto Logout
api.interceptors.response.use(
(response) => response,
(error: AxiosError<ApiErrorResponse>) => {
// Auto-logout on 401
if (error.response?.status === 401) {
useAuthStore.getState().clearSession();
}
// Normalize to Error with human-readable message
const message =
error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
'An unexpected error occurred';
return Promise.reject(new Error(message));
}
);Because errors are normalized to Error, consuming code catches Error — not AxiosError. The .message property always contains a user-friendly string.
Two-Theme System
RNCopilot ships with light and dark themes, both built from the same semantic token structure. The active theme is determined at startup from MMKV storage (or the system color scheme) and can be toggled at runtime:
import { toggleDarkMode } from '@/theme/themeManager';
// Switch to dark mode
toggleDarkMode(true);
// Switch to light mode
toggleDarkMode(false);Theme tokens are automatically available inside any StyleSheet.create callback:
const styles = StyleSheet.create((theme) => ({
container: {
backgroundColor: theme.colors.background.app,
// theme.colors.mode === 'light' | 'dark'
},
}));See Theme System for the complete token reference.
Route Protection
The useProtectedRoute hook checks auth state and redirects unauthenticated users:
export function useProtectedRoute() {
const session = useAuthStore((s) => s.session);
const isLoading = useAuthStore((s) => s.isLoading);
const segments = useSegments();
const router = useRouter();
useEffect(() => {
if (isLoading) return;
const inAuthGroup = segments[0] === '(auth)';
if (!session && !inAuthGroup) {
router.replace('/(auth)/login');
} else if (session && inAuthGroup) {
router.replace('/(main)/(tabs)');
}
}, [session, isLoading, segments, router]);
}Call it at the top of any screen that requires authentication:
export default function DashboardScreen() {
useProtectedRoute();
// ...
}