Skip to main content

State Management

TrickBook uses Zustand for auth state, React Query for server state, and React Context for theming.

Overview

State TypeSolutionPurpose
Auth stateZustandUser session, token, login/logout
Server stateReact QueryAPI data fetching, caching, mutations
ThemeReact ContextDark/Light mode
Form stateReact Hook Form + ZodForm handling and validation
Secure storageExpo Secure StoreJWT 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 PatternNew PatternStatus
React Context (auth)Zustand authStoreMigrated
Manual useState/useEffectReact QueryMigrated
Formik + YupReact Hook Form + ZodMigrated
AsyncStorage (tokens)Expo Secure StoreMigrated
apisauceCustom fetch clientMigrated
Jotai atomsZustand storesMigrated
note

Some legacy code in app/auth/ and app/api/ still uses the old patterns and is being gradually migrated to src/lib/.