Authentication
RNCopilot manages auth state through a Zustand store (useAuthStore), not a React Context provider. This design enables non-React code (like Axios interceptors) to read the session token directly, while components subscribe to specific slices of auth state via selectors.
Auth Store
The store lives at src/providers/auth/authStore.ts:
import { create } from 'zustand';
interface AuthUser {
id: string;
email: string;
[key: string]: unknown;
}
interface AuthSession {
access_token: string;
refresh_token: string;
[key: string]: unknown;
}
interface AuthState {
user: AuthUser | null;
session: AuthSession | null;
isLoading: boolean;
isAuthenticated: boolean;
setUser: (user: AuthUser | null) => void;
setSession: (session: AuthSession | null) => void;
setLoading: (isLoading: boolean) => void;
clearSession: () => void;
initialize: () => Promise<void>;
cleanup: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
session: null,
isLoading: true,
isAuthenticated: false,
setUser: (user) => set({ user, isAuthenticated: !!user }),
setSession: (session) => set({ session }),
setLoading: (isLoading) => set({ isLoading }),
clearSession: () =>
set({ user: null, session: null, isAuthenticated: false }),
initialize: async () => {
// Add your auth initialization logic here
// e.g., check for existing Supabase session
set({ isLoading: false });
},
cleanup: () => {
// Clean up auth subscriptions here
},
}));Initialization with useAuthInit()
The useAuthInit() hook is called in the root layout (app/_layout.tsx). It runs the store’s initialize method on mount and cleanup on unmount:
// app/_layout.tsx
function AppContent() {
useAuthInit(); // Initializes auth state
return (
<View style={styles.appContainer}>
<RootNavigator />
<Toast />
</View>
);
}export function useAuthInit() {
const initialize = useAuthStore((s) => s.initialize);
const cleanup = useAuthStore((s) => s.cleanup);
useEffect(() => {
initialize();
return () => cleanup();
}, [initialize, cleanup]);
}useAuthInit() is called exactly once at the root level. Do not call it in individual screens or components.
Reading Auth State
In React Components (Use Selectors)
import { useAuthStore } from '@/providers/auth/authStore';
function ProfileHeader() {
const user = useAuthStore((s) => s.user);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const isLoading = useAuthStore((s) => s.isLoading);
if (isLoading) return <Loading />;
if (!isAuthenticated) return null;
return <Text>{user?.email}</Text>;
}Always use selectors. Never call useAuthStore() without a selector function.
// WRONG -- subscribes to all state changes
const { user, session, isLoading } = useAuthStore();
// CORRECT -- only re-renders when `user` changes
const user = useAuthStore((s) => s.user);Outside React (Axios Interceptors, Utilities)
For non-React code, use getState() to read the current snapshot:
// src/services/api/client.ts
api.interceptors.request.use((config) => {
const session = useAuthStore.getState().session;
if (session?.access_token) {
config.headers.Authorization = `Bearer ${session.access_token}`;
}
return config;
});getState() returns a snapshot, not a reactive subscription. Only use it in non-React code. Inside React components, always use the hook with a selector.
Supabase Integration
The template is designed to work with Supabase for authentication, but Supabase is optional. The app boots and functions without Supabase credentials.
Connecting Supabase
- Add your credentials to
.env:
EXPO_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
EXPO_PUBLIC_SUPABASE_PUBLISHED_KEY=your-anon-key- Update the
initializemethod inauthStore.tsto check for an existing Supabase session and set up a listener:
initialize: async () => {
try {
const { data: { session } } = await supabase.auth.getSession();
if (session) {
set({
session: {
access_token: session.access_token,
refresh_token: session.refresh_token,
},
user: {
id: session.user.id,
email: session.user.email ?? '',
},
isAuthenticated: true,
});
}
} finally {
set({ isLoading: false });
}
},Graceful Degradation
When Supabase is not configured, the integration file assigns a null stub:
// src/integrations/supabase.ts
if (env.supabaseUrl && env.supabaseAnonKey && isValidUrl(env.supabaseUrl)) {
supabase = createClient(/* ... */);
} else {
supabase = null as unknown as SupabaseClient;
}Any code that calls supabase.* will throw if Supabase is not configured. Guard Supabase-dependent features with a null check or ensure your .env is set up before using them.
Route Protection
The useProtectedRoute hook redirects users based on their auth state:
// src/hooks/useProtectedRoute.ts
import { useRouter, useSegments } from 'expo-router';
import { useEffect } from 'react';
import { useAuthStore } from '@/providers/auth/authStore';
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]);
}Usage
Call it at the top of any screen that requires authentication:
export default function DashboardScreen() {
useProtectedRoute();
const user = useAuthStore((s) => s.user);
// ... rest of screen
}The hook handles three cases:
| Scenario | Action |
|---|---|
No session + not in (auth) group | Redirect to /(auth)/login |
Has session + in (auth) group | Redirect to /(main)/(tabs) |
| Auth still loading | Do nothing (wait) |
Why Not React Context?
| Concern | React Context | Zustand Store |
|---|---|---|
| Access outside React | Requires workarounds | getState() works anywhere |
| Re-render scope | All consumers re-render | Only subscribers of changed slices |
| Testing | Wrap in <AuthProvider> | Set state directly |
| Boilerplate | Provider + Context + hook | Single create() call |
The Zustand approach is simpler, more performant, and enables the Axios interceptor pattern that auto-attaches tokens without any React context.