Skip to Content
Core ConceptsArchitecture

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

LayerTechnologyVersionPurpose
FrameworkReact Native + Expo0.83.2 / SDK 55Cross-platform mobile runtime
Routingexpo-routerFile-basedTyped, file-system routing
LanguageTypeScript5.9 (strict)Static typing with path aliases
Stylingreact-native-unistyles3.xTheme-aware StyleSheet with variants
Server State@tanstack/react-queryCaching, refetching, mutations
Client StateZustandLightweight stores with selectors
BackendSupabaseAuth, database, storage (optional)
API ClientAxiosInterceptors for auth and errors
i18nreact-i18nextEN/AR with RTL support
Storagereact-native-mmkvFast, synchronous key-value persistence
Formsreact-hook-form + ZodSchema-first validation
TestingJest 30 + jest-expoUnit and integration tests
LintingESLint 9Flat configCode 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

  1. GestureHandlerRootView must wrap the entire tree for gesture-based components (bottom sheets, swipe actions).
  2. ErrorBoundary catches unhandled errors in the entire app, including provider initialization failures.
  3. QueryProvider sets up React Query with an MMKV-based persister, so cached data survives app restarts.
  4. BottomSheetModalProvider enables bottom sheet modals anywhere in the tree.
  5. 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().session without 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(); // ... }
Last updated on