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
npx expo install zustand
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) })),
}));
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.
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.
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
Build a persisted cart with lifecycle logging
A small shopping-cart screen backed by Zustand, surviving restarts, with an app-state log.
Requirements:
- A Zustand store for cart items (
add,remove,clear) wrapped inpersistusingAsyncStorage. - A screen listing cart items with quantities and a running total, reading only the slices of state each part needs.
- Force-quit and relaunch the app (or reload in dev) to confirm the cart survives.
- A
useAppStateChangehook logging "app resumed" to the console whenever the app returns toactivefrom background. - A simple in-app log (last 5 entries) showing background/foreground transitions, rendered on screen.
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?
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?
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?
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?
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.