Skip to main content

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.

Two Types of State 🌐 Server State • Lives on a remote server • Can become stale • Needs synchronization • Shared across clients • You don't own it Examples: User profiles, posts, products, comments, orders, messages Use: React Query / SWR 📱 Client State • Lives in the app • Always current • No network needed • Specific to this client • You control it fully Examples: UI state, filters, modals, theme, cart, form drafts Use: Zustand / Redux / Context

📖 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 shallow when 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.