modern-react-spa

Chapter 13

Global State — Zustand, Jotai, Recoil, Redux Toolkit

Picking a global-state library for React 19 SPAs — Zustand, Jotai, Recoil, Redux Toolkit. Selectors, render storms, the 7-question checklist, and why useContext is rarely the answer.

Published 2026-05-23

🎯 Chapter Goal — After this chapter you can pick a global-state library based on your team’s needs (not the loudest blog post), write a Zustand store with sliced state and selectors that don’t cause re-render storms, recognise when Jotai’s atomic model is the right fit, know Recoil’s current status (and what to migrate to), and judge when Redux Toolkit still makes sense.

🧭 Prerequisites — Ch 12 (the four kinds of state) — read it first to confirm you actually want global state.


🔹 13.1 Zustand — minimal, store-based

The book’s recommended default. Tiny API, no Provider needed at the root, selectors avoid re-render storms.

import { create } from 'zustand';
import { persist } from 'zustand/middleware';

type CartItem = { id: string; name: string; price: number };
type CartState = {
  items: CartItem[];
  add:    (item: CartItem) => void;
  remove: (id: string) => void;
  total:  () => number;
};

export const useCart = create<CartState>()(
  persist(
    (set, get) => ({
      items: [],
      add:    (item) => set((s) => ({ items: [...s.items, item] })),
      remove: (id)   => set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
      total:  ()     => get().items.reduce((sum, i) => sum + i.price, 0),
    }),
    { name: 'acme-cart' },          // localStorage key
  ),
);

Consuming with selectors (the avoid-storm pattern):

// Subscribes to the WHOLE store — re-renders on any change. Wrong.
const all = useCart();

// Subscribes to ONE field — re-renders only when items change. Right.
const items = useCart((s) => s.items);
const total = useCart((s) => s.total());

Selectors are the difference between a store that scales and one that doesn’t.

Slicing

const useStore = create<AppState>()((set, get) => ({
  ...createCartSlice(set, get),
  ...createAuthSlice(set, get),
  ...createUiSlice(set, get),
}));

Each slice is a self-contained { state, actions } shape. Compose at the top-level store. Per-slice files keep ownership clear.

Middleware: persist (localStorage / sessionStorage), devtools (Redux DevTools integration), subscribeWithSelector (subscribe outside React), immer (mutate-style updates).

🔹 13.2 Jotai — atomic, derived

The “small atoms” model. Each piece of state is its own atom; components subscribe to specific atoms; derived atoms compose.

import { atom, useAtom } from 'jotai';

const itemsAtom = atom<CartItem[]>([]);
const totalAtom = atom((get) =>
  get(itemsAtom).reduce((s, i) => s + i.price, 0),
);

// In a component:
const [items, setItems] = useAtom(itemsAtom);
const total = useAtomValue(totalAtom);

Async atoms for server data (though TanStack Query is usually the better tool — Ch 14):

const userAtom = atom(async () => fetch('/api/me').then((r) => r.json()));
// useAtom suspends until the promise resolves.

When Jotai wins over Zustand:

  • Heavy use of derived values that change frequently — Jotai’s dependency tracking is finer-grained.
  • You think in atoms rather than stores (a culture/taste call).
  • You need each piece of state in its own module without a centralised registry.

When it loses:

  • Large monolithic state shapes feel awkward as 200 atoms.
  • DevTools experience is weaker than Zustand’s (which piggybacks on Redux DevTools).

🔹 13.3 Recoil — current status and migration

Recoil was Meta’s atomic state library. Status as of 2026: maintenance mode. No active development; bug fixes only.

If you’re already on Recoil: stay until a migration is worth doing; the library still works.

If you’re starting fresh: don’t. The shape of Recoil is closest to Jotai — when you migrate, Jotai is the lowest-friction destination. The mechanical translation:

// Recoil
const itemsState = atom<CartItem[]>({ key: 'items', default: [] });
const totalSelector = selector({
  key: 'total',
  get: ({ get }) => get(itemsState).reduce(/* … */),
});

// Jotai equivalent
const itemsAtom = atom<CartItem[]>([]);
const totalAtom = atom((get) => get(itemsAtom).reduce(/* … */));

Jotai drops the string keys (Recoil needed them for SSR; Jotai uses references). Otherwise the model is identical.


🔹 13.4 Redux Toolkit / RTK Query — when Redux still makes sense

Redux is not dead. It’s the right tool when:

  • You have an existing Redux codebase that works. Migration for migration’s sake is anti-value.
  • You need the DevTools experience — time-travel debugging, action replay, structured state inspection. Nothing else has this depth.
  • You have a complex state machine with many actions producing many state shapes — Redux’s reducer model excels here.
  • You’re using RTK Query as your server-cache solution — it’s a Redux-aware competitor to TanStack Query, fine if you’re already on Redux.
import { createSlice, configureStore } from '@reduxjs/toolkit';

const cartSlice = createSlice({
  name: 'cart',
  initialState: { items: [] as CartItem[] },
  reducers: {
    added:   (s, a: PayloadAction<CartItem>) => { s.items.push(a.payload); },   // immer-backed
    removed: (s, a: PayloadAction<string>)   => { s.items = s.items.filter((i) => i.id !== a.payload); },
  },
});

export const store = configureStore({ reducer: { cart: cartSlice.reducer } });

Redux Toolkit is the modern Redux API. If you write Redux in 2026, write it this way; the old combineReducers + action-creators ceremony is deprecated style.


🔹 13.5 Picking one — the 7-question checklist

  1. Does the team already have a global-state library that works? Stay. Migrations are expensive.
  2. Do you need time-travel debugging? Redux Toolkit.
  3. Do you think in stores or atoms? Stores → Zustand. Atoms → Jotai.
  4. Are you mostly fetching server data? Neither — use TanStack Query (Ch 14).
  5. Do you have heavy derived state that changes frequently? Jotai’s tracking is finer-grained.
  6. Do you want zero Provider ceremony at the root? Zustand (no provider needed).
  7. Are you on Recoil today? Stay until a migration buys something concrete; then Jotai.

Recommended default: Zustand. Smallest API, smallest learning curve, no provider ceremony, scales.


🔹 13.6 The “useContext as global state” trap (lab)

The most common bug pattern in 2026 React.

The broken setup:

type AppState = { user: User; theme: Theme; cart: Cart; notifications: Notif[] };

const AppContext = createContext<AppState | null>(null);

// In <App>:
const [state, setState] = useState<AppState>(initial);
<AppContext value={state}>{children}</AppContext>

// In any consumer:
const { theme } = useContext(AppContext);   // re-renders on EVERY field change

Profile it (React DevTools Profiler): updating one notification re-renders every component reading theme, every component reading user, every component reading cart.

The fix — Zustand:

const useApp = create<AppState>((set) => ({ /* … */ }));

const theme = useApp((s) => s.theme);   // re-renders only on theme change

Or split into multiple contexts (one per concern) if you really want native React. Either way, the lesson is: a single Context for the whole app is an anti-pattern.


🪤 Common Pitfalls

  1. Reaching for Redux when you really need server cache (TanStack Query).
  2. Using useContext for high-frequency state.
  3. Subscribing to the whole Zustand store instead of selecting.
  4. Mutating Jotai atoms instead of returning new values.
  5. Storing form input in a global store.
  6. Per-component-state Zustand “stores” (useFooFieldStore) — that’s just useState with extra steps.
  7. Building a Redux Toolkit slice for something three components read once at mount — overkill.

✅ Recap

  • Zustand is the safe default in 2026: tiny API, selectors, no provider.
  • Jotai if you think in atoms and have derived-heavy state.
  • Recoil → migrate to Jotai when a migration pays.
  • Redux Toolkit when you have a real Redux codebase or need its DevTools depth.
  • Never use a single root Context for unrelated values.

🔗 Further Reading

In the book — not on the site

Each topic has an 🧠 Under-the-hood subsection — the algorithm, the data structures, what React DevTools surfaces, debugging recipes. Plus a 🧪 hands-on lab per chapter with a starter repo. Reserved for the book.

Topics in this chapter (6)
  1. 13.1 Zustand — minimal, store-based
  2. 13.2 Jotai — atomic, derived
  3. 13.3 Recoil — current status and migration
  4. 13.4 Redux Toolkit / RTK Query — when Redux still makes sense
  5. 13.5 Picking one — the 7-question checklist
  6. 13.6 The “useContext as global state” trap (lab)