State Management
TrickBook uses Zustand for auth state, React Query for server state, and React Context for theming.
Overview
| State Type | Solution | Purpose |
|---|---|---|
| Auth state | Zustand | User session, token, login/logout |
| Server state | React Query | API data fetching, caching, mutations |
| Theme | React Context | Dark/Light mode |
| Form state | React Hook Form + Zod | Form handling and validation |
| Secure storage | Expo Secure Store | JWT token persistence |
Authentication State (Zustand)
Global auth state using Zustand with Expo Secure Store for persistence.
Store Definition
// src/lib/stores/authStore.ts
import { create } from 'zustand';
import * as SecureStore from 'expo-secure-store';
import jwtDecode from 'jwt-decode';
interface AuthState {
user: User | null;
token: string | null;
isLoading: boolean;
login: (token: string, user: User) => Promise<void>;
logout: () => Promise<void>;
loadStoredAuth: () => Promise<void>;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
token: null,
isLoading: true,
login: async (token, user) => {
await SecureStore.setItemAsync('authToken', token);
set({ user, token, isLoading: false });
},
logout: async () => {
await SecureStore.deleteItemAsync('authToken');
set({ user: null, token: null });
},
loadStoredAuth: async () => {
const token = await SecureStore.getItemAsync('authToken');
if (token) {
const decoded = jwtDecode(token);
// Fetch full user profile from API
const user = await fetchUserProfile(decoded);
set({ user, token, isLoading: false });
} else {
set({ isLoading: false });
}
},
}));
Usage in Components
import { useAuthStore } from '@/lib/stores/authStore';
const ProfileScreen = () => {
const { user, logout } = useAuthStore();
return (
<View>
<Text>Hello, {user?.name}</Text>
<Button title="Logout" onPress={logout} />
</View>
);
};
Server State (React Query)
All API data is managed through React Query for automatic caching, background refetching, and optimistic updates.
Query Client Setup
// app/_layout.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 2,
},
},
});
export default function RootLayout() {
return (
<QueryClientProvider client={queryClient}>
{/* ... */}
</QueryClientProvider>
);
}
Fetching Data (useQuery)
import { useQuery } from '@tanstack/react-query';
import { trickbookApi } from '@/lib/api/trickbook';
const TrickListsScreen = () => {
const {
data: trickLists,
isLoading,
error,
refetch,
} = useQuery({
queryKey: ['trickLists'],
queryFn: trickbookApi.getTrickLists,
});
if (isLoading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;
return (
<FlatList
data={trickLists}
refreshing={false}
onRefresh={refetch}
renderItem={({ item }) => <TrickListCard list={item} />}
/>
);
};
Mutations (useMutation)
import { useMutation, useQueryClient } from '@tanstack/react-query';
const CreateTrickList = () => {
const queryClient = useQueryClient();
const createMutation = useMutation({
mutationFn: trickbookApi.createTrickList,
onSuccess: () => {
// Invalidate and refetch trick lists
queryClient.invalidateQueries({ queryKey: ['trickLists'] });
},
});
const handleCreate = (name: string) => {
createMutation.mutate({ name });
};
return (
<Button
title="Create List"
onPress={() => handleCreate('New List')}
disabled={createMutation.isPending}
/>
);
};
Query Key Patterns
// Common query keys used across the app
const queryKeys = {
trickLists: ['trickLists'],
trickList: (id: string) => ['trickList', id],
tricks: (listId: string) => ['tricks', listId],
trickipedia: (filters?: object) => ['trickipedia', filters],
spots: ['spots'],
spot: (id: string) => ['spot', id],
spotLists: ['spotLists'],
spotReviews: (spotId: string) => ['spotReviews', spotId],
feed: ['feed'],
feedPost: (id: string) => ['feedPost', id],
homies: ['homies'],
conversations: ['conversations'],
messages: (conversationId: string) => ['messages', conversationId],
userProfile: (id: string) => ['userProfile', id],
userStats: (id: string) => ['userStats', id],
};
Theme State (React Context)
Dark/Light mode managed via React Context with persistent preference.
// src/lib/providers/ThemeProvider.tsx
import { createContext, useContext, useState, useEffect } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
type Theme = 'dark' | 'light';
const ThemeContext = createContext<{
theme: Theme;
toggleTheme: () => void;
}>({ theme: 'dark', toggleTheme: () => {} });
export const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState<Theme>('dark');
useEffect(() => {
AsyncStorage.getItem('theme').then((stored) => {
if (stored) setTheme(stored as Theme);
});
}, []);
const toggleTheme = () => {
const next = theme === 'dark' ? 'light' : 'dark';
setTheme(next);
AsyncStorage.setItem('theme', next);
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = () => useContext(ThemeContext);
Form State (React Hook Form + Zod)
Forms use React Hook Form for efficient re-renders and Zod for schema validation.
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const loginSchema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(5, 'Password must be at least 5 characters'),
});
type LoginForm = z.infer<typeof loginSchema>;
const LoginScreen = () => {
const { login } = useAuthStore();
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginForm>({
resolver: zodResolver(loginSchema),
defaultValues: { email: '', password: '' },
});
const onSubmit = async (data: LoginForm) => {
const response = await authApi.login(data.email, data.password);
if (response.token) {
await login(response.token, response.user);
}
};
return (
<View>
<Controller
control={control}
name="email"
render={({ field: { onChange, value } }) => (
<TextInput
placeholder="Email"
value={value}
onChangeText={onChange}
keyboardType="email-address"
autoCapitalize="none"
/>
)}
/>
{errors.email && <Text>{errors.email.message}</Text>}
<Controller
control={control}
name="password"
render={({ field: { onChange, value } }) => (
<TextInput
placeholder="Password"
value={value}
onChangeText={onChange}
secureTextEntry
/>
)}
/>
{errors.password && <Text>{errors.password.message}</Text>}
<Button
title={isSubmitting ? 'Logging in...' : 'Login'}
onPress={handleSubmit(onSubmit)}
disabled={isSubmitting}
/>
</View>
);
};
State Flow Diagram
┌─────────────────────────────────────────────────────────────┐
│ app/_layout.tsx │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Zustand │ │ React Query │ │ ThemeProvider │ │
│ │ AuthStore │ │ Client │ │ (Context) │ │
│ │ │ │ │ │ │ │
│ │ user │ │ queries │ │ theme: dark/light│ │
│ │ token │ │ mutations │ │ toggleTheme() │ │
│ │ login() │ │ cache │ │ │ │
│ │ logout() │ │ │ │ │ │
│ └──────┬───────┘ └──────┬───────┘ └──────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Expo │ │ API Client │ │ React Hook Form │ │
│ │ Secure Store │ │ (fetch) │ │ + Zod │ │
│ │ │ │ │ │ │ │
│ │ JWT Token │ │ Auto-auth │ │ Form validation │ │
│ │ (encrypted) │ │ Retry logic │ │ Type inference │ │
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Migration Notes
The app is migrating from legacy patterns to the new stack:
| Old Pattern | New Pattern | Status |
|---|---|---|
| React Context (auth) | Zustand authStore | Migrated |
| Manual useState/useEffect | React Query | Migrated |
| Formik + Yup | React Hook Form + Zod | Migrated |
| AsyncStorage (tokens) | Expo Secure Store | Migrated |
| apisauce | Custom fetch client | Migrated |
| Jotai atoms | Zustand stores | Migrated |
note
Some legacy code in app/auth/ and app/api/ still uses the old patterns and is being gradually migrated to src/lib/.