Module 7: Data Management and Networking
State Management at Scale
When local state and React Query aren't enough
🎯 Learning Objectives
- Recognize when you need global state management beyond React Context
- Understand the difference between server state and client state
- Implement lightweight global state with Zustand
- Use Redux Toolkit for complex state requirements
- Persist state to local storage for app restarts
- Combine state management with React Query effectively
- Choose the right state management approach for your app
When Context Isn't Enough
React Context is great for simple global state like themes, authentication status, or user preferences. But as your app grows, you'll hit limitations that make Context frustrating to work with.
😫 Context Pain Points
- Re-render avalanche: Any context change re-renders ALL consumers, even if they only use part of the state
- Provider hell: Multiple contexts mean deeply nested providers
- Boilerplate explosion: Each piece of state needs its own context, provider, and hook
- No middleware: No built-in way to intercept or transform state changes
- Debugging difficulties: Hard to trace state changes across the app
- No persistence: State resets on every app restart
Let's see the re-render problem in action:
// ❌ The Context Problem
const AppContext = createContext<{
user: User | null;
theme: 'light' | 'dark';
notifications: Notification[];
cart: CartItem[];
}>(null!);
function App() {
const [state, setState] = useState({
user: null,
theme: 'light',
notifications: [],
cart: [],
});
return (
<AppContext.Provider value={state}>
<Header /> {/* Uses user, theme */}
<ProductList /> {/* Uses cart */}
<Footer /> {/* Uses theme */}
</AppContext.Provider>
);
}
// Problem: When a notification arrives...
// - Header re-renders (even though it doesn't use notifications)
// - ProductList re-renders (even though it doesn't use notifications)
// - Footer re-renders (even though it doesn't use notifications)
// - EVERY component using this context re-renders!
You can split into multiple contexts, but that leads to provider hell:
// ❌ Provider Hell
function App() {
return (
<AuthProvider>
<ThemeProvider>
<NotificationProvider>
<CartProvider>
<NavigationProvider>
<ModalProvider>
<ToastProvider>
<ActualApp />
</ToastProvider>
</ModalProvider>
</NavigationProvider>
</CartProvider>
</NotificationProvider>
</ThemeProvider>
</AuthProvider>
);
}
✅ When Context IS Enough
Context is still perfect for:
- Theme (changes infrequently, affects everything anyway)
- Authenticated user (changes rarely)
- Locale/language settings
- Feature flags
- Any state that changes rarely and is read by many components
Server State vs Client State
Before adding a state management library, understand what kind of state you're dealing with. This is crucial for choosing the right tool.
📖 The Key Insight
React Query handles server state. Zustand/Redux handles client state. They solve different problems and work great together. Don't use Redux for API data, and don't use React Query for UI state.
Examples of Client State
// Client state examples - perfect for Zustand/Redux
interface ClientState {
// UI State
isMenuOpen: boolean;
activeTab: string;
selectedFilters: string[];
sortOrder: 'asc' | 'desc';
// User Preferences (not from server)
theme: 'light' | 'dark';
fontSize: 'small' | 'medium' | 'large';
// Ephemeral State
formDraft: Partial<FormData>;
searchQuery: string;
// Shopping Cart (until checkout)
cartItems: CartItem[];
// Auth State (token, not user profile)
isAuthenticated: boolean;
// Notifications (UI, not from API)
toasts: Toast[];
}
Zustand: The Lightweight Champion
Zustand (German for "state") is a small, fast, and scalable state management library. It has minimal boilerplate, great TypeScript support, and no provider wrappers needed.
Why Zustand?
🚀 Zustand Benefits
- Tiny: ~1KB gzipped
- No providers: Use stores anywhere, no context wrapper needed
- Selective re-renders: Components only re-render when their selected state changes
- Simple API: Just create a store and use it
- Middleware support: Persistence, devtools, immer, and more
- TypeScript-first: Excellent type inference
Installation
npx expo install zustand
Your First Store
// stores/counterStore.ts
import { create } from 'zustand';
interface CounterState {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
}
export const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));
// Usage in component - no provider needed!
function Counter() {
const count = useCounterStore((state) => state.count);
const increment = useCounterStore((state) => state.increment);
return (
<View>
<Text>Count: {count}</Text>
<Button title="+" onPress={increment} />
</View>
);
}
A Real-World Store: Shopping Cart
// stores/cartStore.ts
import { create } from 'zustand';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
image: string;
}
interface CartState {
items: CartItem[];
// Computed values (not stored, calculated)
totalItems: () => number;
totalPrice: () => number;
// Actions
addItem: (item: Omit<CartItem, 'quantity'>) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
clearCart: () => void;
}
export const useCartStore = create<CartState>((set, get) => ({
items: [],
// Computed values using get()
totalItems: () => {
return get().items.reduce((sum, item) => sum + item.quantity, 0);
},
totalPrice: () => {
return get().items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
},
addItem: (newItem) => {
set((state) => {
const existingItem = state.items.find((item) => item.id === newItem.id);
if (existingItem) {
// Increase quantity if already in cart
return {
items: state.items.map((item) =>
item.id === newItem.id
? { ...item, quantity: item.quantity + 1 }
: item
),
};
}
// Add new item with quantity 1
return {
items: [...state.items, { ...newItem, quantity: 1 }],
};
});
},
removeItem: (id) => {
set((state) => ({
items: state.items.filter((item) => item.id !== id),
}));
},
updateQuantity: (id, quantity) => {
set((state) => {
if (quantity <= 0) {
return { items: state.items.filter((item) => item.id !== id) };
}
return {
items: state.items.map((item) =>
item.id === id ? { ...item, quantity } : item
),
};
});
},
clearCart: () => set({ items: [] }),
}));
// Usage
function CartScreen() {
const items = useCartStore((state) => state.items);
const totalPrice = useCartStore((state) => state.totalPrice);
const removeItem = useCartStore((state) => state.removeItem);
return (
<View>
<FlatList
data={items}
renderItem={({ item }) => (
<CartItemRow
item={item}
onRemove={() => removeItem(item.id)}
/>
)}
/>
<Text>Total: ${totalPrice().toFixed(2)}</Text>
</View>
);
}
// Adding to cart from product screen
function ProductCard({ product }: { product: Product }) {
const addItem = useCartStore((state) => state.addItem);
return (
<Pressable onPress={() => addItem(product)}>
<Text>Add to Cart</Text>
</Pressable>
);
}
Selective Subscriptions (Avoiding Re-renders)
The magic of Zustand is that components only re-render when their specific slice of state changes:
// ✅ This component ONLY re-renders when items change
function CartBadge() {
const itemCount = useCartStore((state) => state.items.length);
return <Badge count={itemCount} />;
}
// ✅ This component ONLY re-renders when totalPrice changes
function CartTotal() {
const totalPrice = useCartStore((state) => state.totalPrice());
return <Text>Total: ${totalPrice.toFixed(2)}</Text>;
}
// ✅ Select multiple values with shallow comparison
import { shallow } from 'zustand/shallow';
function CartSummary() {
const { items, totalPrice } = useCartStore(
(state) => ({
items: state.items,
totalPrice: state.totalPrice(),
}),
shallow // Use shallow comparison for object selector
);
return (
<View>
<Text>{items.length} items</Text>
<Text>${totalPrice.toFixed(2)}</Text>
</View>
);
}
⚠️ Selector Best Practices
- Always use selectors to pick only what you need
- Use
shallowwhen selecting multiple values as an object - Avoid selecting the entire store:
useStore()re-renders on ANY change - Create stable selectors for complex derivations
Zustand Patterns and Best Practices
Slicing Stores
For larger apps, split your store into slices:
// stores/slices/userSlice.ts
import { StateCreator } from 'zustand';
export interface UserSlice {
user: User | null;
isAuthenticated: boolean;
setUser: (user: User | null) => void;
logout: () => void;
}
export const createUserSlice: StateCreator<UserSlice> = (set) => ({
user: null,
isAuthenticated: false,
setUser: (user) => set({ user, isAuthenticated: !!user }),
logout: () => set({ user: null, isAuthenticated: false }),
});
// stores/slices/settingsSlice.ts
export interface SettingsSlice {
theme: 'light' | 'dark';
language: string;
setTheme: (theme: 'light' | 'dark') => void;
setLanguage: (language: string) => void;
}
export const createSettingsSlice: StateCreator<SettingsSlice> = (set) => ({
theme: 'light',
language: 'en',
setTheme: (theme) => set({ theme }),
setLanguage: (language) => set({ language }),
});
// stores/appStore.ts - Combine slices
import { create } from 'zustand';
import { createUserSlice, UserSlice } from './slices/userSlice';
import { createSettingsSlice, SettingsSlice } from './slices/settingsSlice';
type AppState = UserSlice & SettingsSlice;
export const useAppStore = create<AppState>()((...a) => ({
...createUserSlice(...a),
...createSettingsSlice(...a),
}));
Async Actions
// stores/authStore.ts
import { create } from 'zustand';
import * as SecureStore from 'expo-secure-store';
import { api } from '../api/client';
interface AuthState {
user: User | null;
isLoading: boolean;
error: string | null;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
checkAuth: () => Promise<void>;
}
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
isLoading: true,
error: null,
login: async (email, password) => {
set({ isLoading: true, error: null });
try {
const response = await api.post('/auth/login', { email, password });
const { user, token } = response.data;
await SecureStore.setItemAsync('authToken', token);
set({ user, isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Login failed',
isLoading: false
});
throw error;
}
},
logout: async () => {
await SecureStore.deleteItemAsync('authToken');
set({ user: null });
},
checkAuth: async () => {
set({ isLoading: true });
try {
const token = await SecureStore.getItemAsync('authToken');
if (!token) {
set({ user: null, isLoading: false });
return;
}
const response = await api.get('/auth/me');
set({ user: response.data, isLoading: false });
} catch (error) {
await SecureStore.deleteItemAsync('authToken');
set({ user: null, isLoading: false });
}
},
}));
Using Store Outside React
Zustand stores work outside React components—great for API interceptors, navigation, etc.:
// api/client.ts
import { useAuthStore } from '../stores/authStore';
// Access store directly (not a hook!)
const logout = useAuthStore.getState().logout;
const getUser = () => useAuthStore.getState().user;
// Use in API interceptor
api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
// Token expired - logout
await logout();
// Redirect to login...
}
return Promise.reject(error);
}
);
// Subscribe to changes outside React
const unsubscribe = useAuthStore.subscribe(
(state) => state.user,
(user) => {
console.log('User changed:', user);
}
);
Redux Toolkit: For Complex Apps
Redux Toolkit (RTK) is the modern way to use Redux. It eliminates the boilerplate that made Redux infamous. Use RTK when you need Redux DevTools, complex middleware, or are already familiar with Redux.
When to Consider Redux
🤔 Consider Redux When:
- Your team already knows Redux
- You need extensive DevTools debugging
- You have complex state update logic
- You need middleware for side effects
- You're migrating from an existing Redux codebase
For most React Native apps, Zustand is simpler and sufficient.
Installation
npx expo install @reduxjs/toolkit react-redux
Creating a Slice
// store/slices/cartSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartState {
items: CartItem[];
}
const initialState: CartState = {
items: [],
};
const cartSlice = createSlice({
name: 'cart',
initialState,
reducers: {
addItem: (state, action: PayloadAction<Omit<CartItem, 'quantity'>>) => {
const existing = state.items.find((item) => item.id === action.payload.id);
if (existing) {
existing.quantity += 1;
} else {
state.items.push({ ...action.payload, quantity: 1 });
}
},
removeItem: (state, action: PayloadAction<string>) => {
state.items = state.items.filter((item) => item.id !== action.payload);
},
updateQuantity: (
state,
action: PayloadAction<{ id: string; quantity: number }>
) => {
const { id, quantity } = action.payload;
if (quantity <= 0) {
state.items = state.items.filter((item) => item.id !== id);
} else {
const item = state.items.find((item) => item.id === id);
if (item) {
item.quantity = quantity;
}
}
},
clearCart: (state) => {
state.items = [];
},
},
});
export const { addItem, removeItem, updateQuantity, clearCart } = cartSlice.actions;
export default cartSlice.reducer;
// Selectors
export const selectCartItems = (state: RootState) => state.cart.items;
export const selectCartTotal = (state: RootState) =>
state.cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
export const selectCartItemCount = (state: RootState) =>
state.cart.items.reduce((sum, item) => sum + item.quantity, 0);
Store Setup
// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import cartReducer from './slices/cartSlice';
import userReducer from './slices/userSlice';
export const store = configureStore({
reducer: {
cart: cartReducer,
user: userReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// Typed hooks
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
Provider Setup
// app/_layout.tsx
import { Stack } from 'expo-router';
import { Provider } from 'react-redux';
import { store } from '../store';
export default function RootLayout() {
return (
<Provider store={store}>
<Stack />
</Provider>
);
}
Usage in Components
// components/CartScreen.tsx
import { View, Text, FlatList } from 'react-native';
import { useAppSelector, useAppDispatch } from '../store';
import {
selectCartItems,
selectCartTotal,
removeItem
} from '../store/slices/cartSlice';
function CartScreen() {
const items = useAppSelector(selectCartItems);
const total = useAppSelector(selectCartTotal);
const dispatch = useAppDispatch();
return (
<View>
<FlatList
data={items}
renderItem={({ item }) => (
<CartItemRow
item={item}
onRemove={() => dispatch(removeItem(item.id))}
/>
)}
/>
<Text>Total: ${total.toFixed(2)}</Text>
</View>
);
}
State Persistence
Both Zustand and Redux Toolkit support persisting state to storage, so your app state survives restarts.
Zustand Persistence with MMKV
// stores/cartStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { MMKV } from 'react-native-mmkv';
const storage = new MMKV({ id: 'cart-storage' });
// MMKV storage adapter for Zustand
const mmkvStorage = {
getItem: (name: string) => {
const value = storage.getString(name);
return value ?? null;
},
setItem: (name: string, value: string) => {
storage.set(name, value);
},
removeItem: (name: string) => {
storage.delete(name);
},
};
interface CartState {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
clearCart: () => void;
}
export const useCartStore = create<CartState>()(
persist(
(set) => ({
items: [],
addItem: (item) =>
set((state) => ({ items: [...state.items, item] })),
removeItem: (id) =>
set((state) => ({
items: state.items.filter((item) => item.id !== id),
})),
clearCart: () => set({ items: [] }),
}),
{
name: 'cart-storage', // Storage key
storage: createJSONStorage(() => mmkvStorage),
// Optional: Only persist certain fields
partialize: (state) => ({ items: state.items }),
}
)
);
Zustand Persistence with AsyncStorage
// For Expo Go compatibility (no native modules)
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
export const useCartStore = create<CartState>()(
persist(
(set) => ({
items: [],
// ... actions
}),
{
name: 'cart-storage',
storage: createJSONStorage(() => AsyncStorage),
}
)
);
Handling Hydration
Persisted stores need to load data from storage on startup. Zustand handles this automatically, but you might want to show a loading state:
// hooks/useHydration.ts
import { useEffect, useState } from 'react';
import { useCartStore } from '../stores/cartStore';
export function useHydration() {
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
// Zustand persist middleware exposes onFinishHydration
const unsubFinishHydration = useCartStore.persist.onFinishHydration(() => {
setHydrated(true);
});
// Check if already hydrated
if (useCartStore.persist.hasHydrated()) {
setHydrated(true);
}
return () => {
unsubFinishHydration();
};
}, []);
return hydrated;
}
// Usage in app root
function App() {
const hydrated = useHydration();
if (!hydrated) {
return <SplashScreen />;
}
return <MainApp />;
}
Redux Toolkit Persistence
// store/index.ts
import { configureStore, combineReducers } from '@reduxjs/toolkit';
import {
persistStore,
persistReducer,
FLUSH,
REHYDRATE,
PAUSE,
PERSIST,
PURGE,
REGISTER,
} from 'redux-persist';
import AsyncStorage from '@react-native-async-storage/async-storage';
import cartReducer from './slices/cartSlice';
import userReducer from './slices/userSlice';
const persistConfig = {
key: 'root',
storage: AsyncStorage,
whitelist: ['cart'], // Only persist cart, not user
};
const rootReducer = combineReducers({
cart: cartReducer,
user: userReducer,
});
const persistedReducer = persistReducer(persistConfig, rootReducer);
export const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
},
}),
});
export const persistor = persistStore(store);
// app/_layout.tsx
import { Provider } from 'react-redux';
import { PersistGate } from 'redux-persist/integration/react';
import { store, persistor } from '../store';
export default function RootLayout() {
return (
<Provider store={store}>
<PersistGate loading={<SplashScreen />} persistor={persistor}>
<Stack />
</PersistGate>
</Provider>
);
}
⚠️ What NOT to Persist
- Loading states: Should start fresh each session
- Error states: Clear on restart
- Transient UI state: Modal open/closed, etc.
- Sensitive data: Use SecureStore instead
Use partialize (Zustand) or whitelist/blacklist (Redux) to control what gets persisted.
Combining with React Query
React Query and Zustand/Redux are complementary—use React Query for server state and Zustand/Redux for client state. Here's how they work together:
flowchart TB
subgraph Server["🌐 Server State"]
RQ[React Query]
API[(API)]
RQ <--> API
end
subgraph Client["📱 Client State"]
ZS[Zustand Store]
LS[(Local Storage)]
ZS <--> LS
end
subgraph UI["🖼️ React Components"]
C1[ProductList]
C2[Cart]
C3[Settings]
end
RQ --> C1
ZS --> C2
ZS --> C3
RQ --> C2
style RQ fill:#e3f2fd,stroke:#2196F3
style ZS fill:#fff3e0,stroke:#FF9800
Example: E-commerce App
// A component using BOTH React Query and Zustand
import { useQuery } from '@tanstack/react-query';
import { useCartStore } from '../stores/cartStore';
interface Product {
id: string;
name: string;
price: number;
image: string;
}
function ProductCard({ productId }: { productId: string }) {
// Server state: Product data from API
const { data: product, isLoading } = useQuery({
queryKey: ['products', productId],
queryFn: () => fetchProduct(productId),
});
// Client state: Cart from Zustand
const cartItems = useCartStore((state) => state.items);
const addToCart = useCartStore((state) => state.addItem);
// Derived state: Is this product in cart?
const isInCart = cartItems.some((item) => item.id === productId);
const cartQuantity = cartItems.find((item) => item.id === productId)?.quantity ?? 0;
if (isLoading || !product) {
return <ProductSkeleton />;
}
return (
<View style={styles.card}>
<Image source={{ uri: product.image }} style={styles.image} />
<Text style={styles.name}>{product.name}</Text>
<Text style={styles.price}>${product.price.toFixed(2)}</Text>
{isInCart ? (
<View style={styles.inCart}>
<Text>In cart: {cartQuantity}</Text>
</View>
) : (
<Pressable
style={styles.addButton}
onPress={() => addToCart({
id: product.id,
name: product.name,
price: product.price,
image: product.image,
})}
>
<Text style={styles.addButtonText}>Add to Cart</Text>
</Pressable>
)}
</View>
);
}
Syncing Client State with Server
Sometimes you need to sync client state (like a cart) with the server:
// stores/cartStore.ts with server sync
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { api } from '../api/client';
interface CartState {
items: CartItem[];
isSyncing: boolean;
lastSyncedAt: number | null;
addItem: (item: CartItem) => void;
syncWithServer: () => Promise<void>;
}
export const useCartStore = create<CartState>()(
persist(
(set, get) => ({
items: [],
isSyncing: false,
lastSyncedAt: null,
addItem: (item) => {
set((state) => ({
items: [...state.items, item],
}));
// Debounced sync (implement debounce as needed)
get().syncWithServer();
},
syncWithServer: async () => {
const { items, isSyncing } = get();
if (isSyncing) return;
set({ isSyncing: true });
try {
await api.post('/cart/sync', { items });
set({ lastSyncedAt: Date.now() });
} catch (error) {
console.error('Cart sync failed:', error);
} finally {
set({ isSyncing: false });
}
},
}),
{
name: 'cart-storage',
partialize: (state) => ({
items: state.items,
lastSyncedAt: state.lastSyncedAt,
}),
}
)
);
// Sync cart when user logs in
function useAuthSync() {
const { user } = useAuthStore();
const syncCart = useCartStore((state) => state.syncWithServer);
useEffect(() => {
if (user) {
syncCart();
}
}, [user, syncCart]);
}
Choosing Your Approach
Here's a decision framework for choosing the right state management approach:
flowchart TD
A[Need global state?] -->|No| B[useState / useReducer]
A -->|Yes| C{What type of state?}
C -->|Server data| D[React Query / SWR]
C -->|Client state| E{Complexity?}
E -->|Simple, rarely changes| F[React Context]
E -->|Medium complexity| G[Zustand]
E -->|Complex, team knows Redux| H[Redux Toolkit]
style D fill:#e3f2fd,stroke:#2196F3
style F fill:#e8f5e9,stroke:#4CAF50
style G fill:#fff3e0,stroke:#FF9800
style H fill:#fce4ec,stroke:#E91E63
📋 Quick Reference
| Scenario | Recommendation |
|---|---|
| API data (users, posts, products) | React Query |
| Theme, language, feature flags | React Context |
| Shopping cart, filters, UI state | Zustand |
| Complex flows, existing Redux app | Redux Toolkit |
| Form state | React Hook Form (next lesson!) |
✅ Our Recommendation
For most React Native apps, this combination works great:
- React Query for all server/API data
- Zustand for global client state (cart, UI, auth status)
- React Context for theme and simple app-wide settings
- Local useState for component-specific state
This gives you the best of all worlds with minimal complexity.
Hands-On Exercises
Exercise 1: Create a Zustand Filter Store
Build a store for managing product list filters.
Requirements:
- Track selected categories (array of strings)
- Track price range (min/max)
- Track sort order (price-asc, price-desc, name-asc, name-desc)
- Actions: toggleCategory, setPriceRange, setSortOrder, resetFilters
- A computed function to check if any filters are active
Show Hint
Use get() to access current state in computed functions. For toggleCategory, check if the category exists and either add or remove it.
Show Solution
// stores/filterStore.ts
import { create } from 'zustand';
type SortOrder = 'price-asc' | 'price-desc' | 'name-asc' | 'name-desc';
interface FilterState {
categories: string[];
priceRange: { min: number; max: number };
sortOrder: SortOrder;
// Actions
toggleCategory: (category: string) => void;
setPriceRange: (min: number, max: number) => void;
setSortOrder: (order: SortOrder) => void;
resetFilters: () => void;
// Computed
hasActiveFilters: () => boolean;
}
const defaultFilters = {
categories: [],
priceRange: { min: 0, max: 1000 },
sortOrder: 'name-asc' as SortOrder,
};
export const useFilterStore = create<FilterState>((set, get) => ({
...defaultFilters,
toggleCategory: (category) => {
set((state) => {
const exists = state.categories.includes(category);
return {
categories: exists
? state.categories.filter((c) => c !== category)
: [...state.categories, category],
};
});
},
setPriceRange: (min, max) => {
set({ priceRange: { min, max } });
},
setSortOrder: (order) => {
set({ sortOrder: order });
},
resetFilters: () => {
set(defaultFilters);
},
hasActiveFilters: () => {
const state = get();
return (
state.categories.length > 0 ||
state.priceRange.min !== 0 ||
state.priceRange.max !== 1000 ||
state.sortOrder !== 'name-asc'
);
},
}));
// Usage
function FilterBar() {
const categories = useFilterStore((s) => s.categories);
const hasFilters = useFilterStore((s) => s.hasActiveFilters());
const resetFilters = useFilterStore((s) => s.resetFilters);
return (
<View>
<Text>Selected: {categories.join(', ')}</Text>
{hasFilters && (
<Button title="Clear Filters" onPress={resetFilters} />
)}
</View>
);
}
Exercise 2: Persisted Favorites Store
Create a favorites store that persists to AsyncStorage.
Requirements:
- Store a list of favorited product IDs
- Actions: toggleFavorite, isFavorite (check if ID is in list)
- Persist favorites to AsyncStorage
- Handle hydration state for initial load
Show Hint
Use Zustand's persist middleware with createJSONStorage(() => AsyncStorage). The isFavorite function can use get().favorites.includes(id).
Show Solution
// stores/favoritesStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
interface FavoritesState {
favorites: string[];
toggleFavorite: (id: string) => void;
isFavorite: (id: string) => boolean;
clearFavorites: () => void;
}
export const useFavoritesStore = create<FavoritesState>()(
persist(
(set, get) => ({
favorites: [],
toggleFavorite: (id) => {
set((state) => {
const exists = state.favorites.includes(id);
return {
favorites: exists
? state.favorites.filter((fId) => fId !== id)
: [...state.favorites, id],
};
});
},
isFavorite: (id) => {
return get().favorites.includes(id);
},
clearFavorites: () => {
set({ favorites: [] });
},
}),
{
name: 'favorites-storage',
storage: createJSONStorage(() => AsyncStorage),
}
)
);
// Hydration hook
export function useFavoritesHydration() {
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
const unsub = useFavoritesStore.persist.onFinishHydration(() => {
setHydrated(true);
});
if (useFavoritesStore.persist.hasHydrated()) {
setHydrated(true);
}
return unsub;
}, []);
return hydrated;
}
// Usage
function FavoriteButton({ productId }: { productId: string }) {
const isFavorite = useFavoritesStore((s) => s.isFavorite(productId));
const toggleFavorite = useFavoritesStore((s) => s.toggleFavorite);
return (
<Pressable onPress={() => toggleFavorite(productId)}>
<Text>{isFavorite ? '❤️' : '🤍'}</Text>
</Pressable>
);
}
Exercise 3: Auth Store with Async Actions
Build a complete authentication store with login, logout, and token refresh.
Requirements:
- Track: user, isAuthenticated, isLoading, error
- Async actions: login, logout, checkAuth (on app start)
- Store tokens in SecureStore (not in Zustand state)
- Handle loading and error states properly
Show Hint
In async actions, set isLoading: true first, then wrap in try-catch. Store tokens with SecureStore, but keep the user object in Zustand state. The checkAuth action should fetch the user if a token exists.
Show Solution
// stores/authStore.ts
import { create } from 'zustand';
import * as SecureStore from 'expo-secure-store';
interface User {
id: string;
email: string;
name: string;
}
interface AuthState {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
checkAuth: () => Promise<void>;
clearError: () => void;
}
const TOKEN_KEY = 'auth_token';
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
isAuthenticated: false,
isLoading: true, // Start true for initial auth check
error: null,
login: async (email, password) => {
set({ isLoading: true, error: null });
try {
// Simulate API call
const response = await fetch('https://api.example.com/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Login failed');
}
const { user, token } = await response.json();
// Store token securely
await SecureStore.setItemAsync(TOKEN_KEY, token);
set({
user,
isAuthenticated: true,
isLoading: false,
});
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Login failed',
isLoading: false,
});
throw error;
}
},
logout: async () => {
set({ isLoading: true });
try {
await SecureStore.deleteItemAsync(TOKEN_KEY);
set({
user: null,
isAuthenticated: false,
isLoading: false,
});
} catch (error) {
set({ isLoading: false });
}
},
checkAuth: async () => {
set({ isLoading: true });
try {
const token = await SecureStore.getItemAsync(TOKEN_KEY);
if (!token) {
set({ isLoading: false });
return;
}
// Validate token with server
const response = await fetch('https://api.example.com/auth/me', {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) {
await SecureStore.deleteItemAsync(TOKEN_KEY);
set({ isLoading: false });
return;
}
const user = await response.json();
set({
user,
isAuthenticated: true,
isLoading: false,
});
} catch (error) {
await SecureStore.deleteItemAsync(TOKEN_KEY);
set({ isLoading: false });
}
},
clearError: () => set({ error: null }),
}));
// Usage in app root
function AppRoot() {
const checkAuth = useAuthStore((s) => s.checkAuth);
const isLoading = useAuthStore((s) => s.isLoading);
useEffect(() => {
checkAuth();
}, []);
if (isLoading) {
return <SplashScreen />;
}
return <RootNavigator />;
}
Summary
State management at scale is about choosing the right tool for each type of state. Don't over-engineer—start simple and add complexity only when needed.
🎯 Key Takeaways
- Server state ≠ Client state—use different tools for each
- React Query for API data (caching, background refresh, mutations)
- Zustand for client state—simple, fast, no providers needed
- Redux Toolkit when you need complex middleware or team knows Redux
- Context is still great for theme, locale, and rarely-changing state
- Selective subscriptions prevent unnecessary re-renders
- Persistence middleware saves state across app restarts
- Combine tools—React Query + Zustand is a powerful combo
In the next lesson, we'll dive into forms and validation—another crucial piece of data management that has its own specialized tools.