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

StateExamplesWhere it lives
Local UIModal open, tab index, draft textuseState in the screen
Shared clientSession, theme, cartA store or context
Server cacheLists fetched from an APIA query library, with caching and retries
PersistedOnboarding complete, last syncAsyncStorage 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 useMemo so 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.

Networking, data fetching and offline support Modern JavaScript and TypeScript for React Native

Last refreshed 2026-09-18.