Week 6: State Management in React Native

React's state tools carry over to mobile largely unchanged — the differences show up around the edges: state has to survive the app being killed and relaunched, and the app itself moves between foreground and background in ways a browser tab never does. This week picks a state approach, persists it, and reacts to those lifecycle transitions.

Phase 3 of 8 Week 6 of 22 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Choose between Context, Zustand and Redux Toolkit for a given screen or app size
  • Set up a Zustand store and read/update it from multiple screens
  • Persist store state to disk so it survives an app restart
  • React to the app moving between active, background and inactive states with AppState

1. Choosing a State Approach

All three options work in React Native exactly as they do on the web — the choice is about scale, not platform.

  • Context + useReducer — no dependency, fine for state a handful of screens share (theme, auth flag), but re-renders every consumer on any change.
  • Zustand — a small store outside React's tree, components subscribe to just the slice they read, no provider wrapping required. The default for most app-sized state going forward in this course.
  • Redux Toolkit — worth it once you have complex, interrelated state, need time-travel debugging, or you're joining a team that already standardized on it.

2. Zustand in Practice

terminal
npx expo install zustand
store/cartStore.ts
import { create } from 'zustand';

type CartItem = { id: string; title: string; qty: number };
type CartState = {
  items: CartItem[];
  addItem: (item: CartItem) => void;
  removeItem: (id: string) => void;
};

export const useCartStore = create<CartState>((set) => ({
  items: [],
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
  removeItem: (id) =>
    set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
}));
reading the store from a screen
function CartScreen() {
  const items = useCartStore((state) => state.items);
  const removeItem = useCartStore((state) => state.removeItem);

  return (
    <FlatList
      data={items}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => (
        <Pressable onPress={() => removeItem(item.id)}>
          <Text>{item.title} x{item.qty}</Text>
        </Pressable>
      )}
    />
  );
}

Selecting state => state.items rather than the whole store means this component only re-renders when items itself changes — a component that only reads removeItem never re-renders when items updates.

3. Persisting State Across Restarts

Zustand's persist middleware writes the store to AsyncStorage (introduced properly in Week 9) on every change and rehydrates it on launch.

store/cartStore.ts — persisted
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: [],
      addItem: (item) => set((state) => ({ items: [...state.items, item] })),
      removeItem: (id) =>
        set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
    }),
    {
      name: 'cart-storage',
      storage: createJSONStorage(() => AsyncStorage),
    }
  )
);

Rehydration is asynchronous — on first render the store may still hold its initial (empty) state for a frame or two. For UI that must not flash empty, check useCartStore.persist.hasHydrated() or subscribe to onFinishHydration before rendering real content.

4. Foreground / Background Transitions

A mobile app is never just "open" — it's active, backgrounded (still in memory, not visible), or inactive (mid-transition, e.g. a call coming in on iOS). The AppState API reports which.

hooks/useAppStateChange.ts
import { useEffect, useRef } from 'react';
import { AppState, AppStateStatus } from 'react-native';

export function useAppStateChange(onChange: (status: AppStateStatus) => void) {
  const appState = useRef(AppState.currentState);

  useEffect(() => {
    const subscription = AppState.addEventListener('change', (nextState) => {
      if (appState.current.match(/inactive|background/) && nextState === 'active') {
        onChange('active'); // app just came back to the foreground
      }
      appState.current = nextState;
    });
    return () => subscription.remove();
  }, [onChange]);
}

The most common real use: re-fetching data or refreshing a session token the moment the user returns to the app, rather than on a timer that keeps running (and draining battery) while the app isn't visible at all.

5. Hands-on Exercise

Hands-on

Build a persisted cart with lifecycle logging

A small shopping-cart screen backed by Zustand, surviving restarts, with an app-state log.

Requirements:

  1. A Zustand store for cart items (add, remove, clear) wrapped in persist using AsyncStorage.
  2. A screen listing cart items with quantities and a running total, reading only the slices of state each part needs.
  3. Force-quit and relaunch the app (or reload in dev) to confirm the cart survives.
  4. A useAppStateChange hook logging "app resumed" to the console whenever the app returns to active from background.
  5. A simple in-app log (last 5 entries) showing background/foreground transitions, rendered on screen.
Hint

If the cart looks empty for a split second after every reload, that's rehydration lag, not a bug — gate your initial render on useCartStore.persist.hasHydrated() (poll it in a small useEffect, or subscribe via onFinishHydration) and show a lightweight loading state until it flips true.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

When would Context + useReducer be the right call instead of Zustand?

For state shared by a small, tightly-scoped part of the tree — a theme flag, a form's local state across a few steps — where the extra dependency and store setup aren't worth it. Context's re-render-every-consumer behavior only becomes a real cost once the state updates frequently or many unrelated components read it.

Q2

Why does selecting state => state.items from a Zustand store avoid unnecessary re-renders, where reading the whole store would not?

Zustand re-renders a component only when the selected slice's value actually changes (by reference or the selector's own equality check), not on every store update. Reading the whole store subscribes to every field, so a change to an unrelated field still triggers a re-render.

Q3

Why might a persisted Zustand store briefly show its initial (empty) state on launch, even though data was saved last session?

AsyncStorage reads are asynchronous, and persist rehydrates the store after that read resolves — so the very first render or two happens before rehydration finishes. Checking hasHydrated() (or the onFinishHydration callback) before rendering real content avoids a flash of empty state.

Q4

What's the practical difference between an app being backgrounded and being killed, from the state-management side?

A backgrounded app stays in memory — its JS runtime, and any in-memory Zustand/Context state, is untouched and resumes instantly when foregrounded. A killed app relaunches with a fresh JS runtime, so only state that was actually persisted to disk (via persist or manual storage calls) survives; anything held only in memory is gone.