State management: Context, Redux Toolkit and Zustand
Split client state from server state, avoid the Context re-render trap, and use Redux Toolkit or Zustand without turning every screen into boilerplate.
Client state versus server state
| State | Examples | Where it lives |
|---|---|---|
| Local UI | Modal open, tab index, draft text | useState in the screen |
| Shared client | Session, theme, cart | A store or context |
| Server cache | Lists fetched from an API | A query library, with caching and retries |
| Persisted | Onboarding complete, last sync | AsyncStorage or MMKV on top of the store |
Most duplication in a React Native app comes from copying server data into a client store and then keeping both in sync. Let the query layer own fetched data and the store own only what the user changed.
Context and its re-render trap
import { createContext, useContext, useMemo, useReducer, type ReactNode } from 'react';
type CartState = { lines: Record<string, number> };
type CartAction = { type: 'add'; sku: string } | { type: 'clear' };
function reducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'add':
return { lines: { ...state.lines, [action.sku]: (state.lines[action.sku] ?? 0) + 1 } };
case 'clear':
return { lines: {} };
}
}
const StateContext = createContext<CartState | null>(null);
const DispatchContext = createContext<((a: CartAction) => void) | null>(null);
export function CartProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(reducer, { lines: {} });
// the dispatch identity is stable, so consumers of it never re-render
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>{children}</DispatchContext.Provider>
</StateContext.Provider>
);
}
export function useCartCount() {
const state = useContext(StateContext);
if (!state) throw new Error('useCartCount must be used inside CartProvider');
return useMemo(() => Object.values(state.lines).reduce((a, b) => a + b, 0), [state.lines]);
}
export function useCartDispatch() {
const dispatch = useContext(DispatchContext);
if (!dispatch) throw new Error('useCartDispatch must be used inside CartProvider');
return dispatch;
}- Split state and dispatch into two contexts so components that only dispatch do not re-render on every state change.
- A provider that builds its value object inline creates a new reference each render and re-renders every consumer.
- Context is not a store: it has no selectors and no devtools, so it suits a handful of low-frequency values.
- Memoise derived values with
useMemoso the object identity is stable between renders.
Redux Toolkit and Zustand
// Redux Toolkit: one slice, typed hooks
import { configureStore, createSlice, type PayloadAction } from '@reduxjs/toolkit';
const cartSlice = createSlice({
name: 'cart',
initialState: { lines: {} as Record<string, number> },
reducers: {
added(state, action: PayloadAction<string>) {
state.lines[action.payload] = (state.lines[action.payload] ?? 0) + 1;
},
cleared(state) {
state.lines = {};
},
},
});
export const { added, cleared } = cartSlice.actions;
export const store = configureStore({ reducer: { cart: cartSlice.reducer } });
export type RootState = ReturnType<typeof store.getState>;
// Zustand: the same idea without actions or a provider
import { create } from 'zustand';
export const useCart = create<{ lines: Record<string, number>; add: (sku: string) => void }>((set) => ({
lines: {},
add: (sku) => set((s) => ({ lines: { ...s.lines, [sku]: (s.lines[sku] ?? 0) + 1 } })),
}));
// select narrowly; selecting the whole object re-renders on any change
const count = useCart((s) => Object.values(s.lines).reduce((a, b) => a + b, 0));⚠️
A selector that returns a new object or array on every call breaks reference equality and re-renders on every store update. Return a primitive, or select the slice and derive the value with
useMemo.FAQ
Do I need Redux in a modern React Native app?
Only if you want time-travel debugging, middleware, or a very large team that benefits from enforced structure. Zustand covers the same client-state needs with far less code, and a query library handles the server half.
Where should fetched API data live?
In a query library such as TanStack Query. It gives you caching, deduplication, retries and background refresh that you would otherwise rebuild badly inside a Redux store.
Related
Networking, data fetching and offline support Modern JavaScript and TypeScript for React Native
Last refreshed 2026-09-18.