Skip to the chapter
←ReactGGroundwork⚛React
React/Intermediate/I12
23 / 57 written chapters
/

The cover
  1. B1Setup & JSX
  2. B2Thinking in React
  3. B3Components
  4. B4Props
  5. B5useState
  6. B6Events & conditionals
  7. B7Lists & keys
  8. B8Forms
  9. B9useEffect
  10. B10Lifting state & styling
  11. B11Guided project
  12. ★Cheat page
  1. I1useRef
  2. I2Context
  3. I3useReducer
  4. I4Custom hooks
  5. I5Effects in depth
  6. I6Routing
  7. I7State architecture
  8. I8Memoisation
  9. I9Boundaries & portals
  10. I10Data fetching
  11. I11Forms at scale
  12. I12State libraries
  13. I13Auth
  14. I14TanStack Query
  15. I15Testing
  16. I16TypeScript
  17. I17Animation
  18. I18E2E testing
  19. I19i18n
  20. I20File uploads
  21. I21Real-time UI
  22. I22shadcn/ui
  23. ★Cheat page
  1. A1Fiber
  2. A2Concurrent
  3. A3Suspense
  4. A4Server Components
  5. A5Server Actions
  6. A6use()
  7. A7Rendering strategies
  8. A8Effect timing
  9. A9External stores
  10. A10Refs in depth
  11. A11Component patterns
  12. A12Performance
  13. A13Accessibility
  14. A14Design systems
  15. A15Advanced TypeScript
  16. A16Architecture at scale
  17. A17Custom renderers
  18. A18Migrating & upgrading
  19. A19Production debugging
  20. A20Review & judgement
  21. A21State machines
  22. A22Feature flags
  23. A23Interview bank
  24. ★Cheat page

⚛I12 · Part 2 · Intermediate

State management libraries

What a store gives you that context cannot: selectors.

5 min read11 sections2 exercises
23 of 57

In this chapter

  • The one thing context cannot do
  • Zustand
  • The selector trap, and useShallow
  • Reading the store outside React
  • Redux Toolkit

and 6 more

about 5 min
11On this page
  1. The one thing context cannot do
  2. Zustand
  3. The selector trap, and useShallow
  4. Reading the store outside React
  5. Redux Toolkit
  6. RTK Query: the server-state half of Redux
  7. Jotai: atoms
  8. Comparison
  9. Choosing, in the order to ask
  10. Keep the store small
  11. Persisting

The one thing context cannot do

Context has no selectors. When the provider value changes, every consumer re-renders — even one that reads a field which did not change. For a theme that is irrelevant. For a cart that updates on every quantity change, it means the whole app re-renders for one number.

const name = useStore((s) => s.user.name);   // re-renders only when name changes

That subscription is what you are buying. Everything else — devtools, middleware, persistence — is convenience on top. Underneath, every library here does the same thing: it keeps state outside React and subscribes through useSyncExternalStore, comparing the selected value before deciding to re-render.

Before reaching for one

Ask what kind of state it is. If it came from a server, the answer is a query cache, not a store — and that removes most of what people put in Redux. If it should survive a refresh or a shared link, it belongs in the URL. What is left is genuinely shared, genuinely client-side, and usually much smaller than expected.

Zustand

const useCart = create((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),
}));

function Badge() {
  const count = useCart((s) => s.items.length);   // subscribes to length only
  return <span>{count}</span>;
}

No provider, no reducer, no action types. The store is a hook. It is the default choice for new projects that need a store at all — a few kilobytes, and the API above is most of the API.

The selector trap, and useShallow

// ✗ a new object on every call
const { items, add } = useCart((s) => ({ items: s.items, add: s.add }));

// ✓ compare the fields, not the wrapper
import { useShallow } from "zustand/react/shallow";
const { items, add } = useCart(useShallow((s) => ({ items: s.items, add: s.add })));

// ✓ or just select twice
const items = useCart((s) => s.items);
const add = useCart((s) => s.add);

The store compares what your selector returns with what it returned last time, by reference. An object literal is new every call, so it always looks changed. In Zustand 4 that meant a wasted re-render on every store update. In Zustand 5 it is worse: the component loops until React stops it with "Maximum update depth exceeded". useShallow compares the object's properties one level deep instead, which is what you meant.

Reading the store outside React

useCart.getState().items;                          // current value, no subscription
useCart.setState({ items: [] });                   // e.g. on logout
const unsubscribe = useCart.subscribe((s) => save(s.items));

Because the store lives outside the component tree, an API client, a websocket handler or a test can read and write it directly. That is also the honest answer to "how do I reset state between tests": call setState with the initial value in a beforeEach.

Redux Toolkit

const cartSlice = createSlice({
  name: "cart",
  initialState: { items: [] },
  reducers: {
    added(state, action) {
      state.items.push(action.payload);    // Immer — safe to "mutate"
    },
  },
});

export const store = configureStore({ reducer: { cart: cartSlice.reducer } });
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

// hooks.ts — typed once, used everywhere
export const useAppSelector = useSelector.withTypes<RootState>();
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();

Modern Redux is not the Redux with the bad reputation. Toolkit removes the boilerplate: no action constants, no switch statements, no manual immutability — Immer lets you write mutations and produces an immutable result. The types are inferred from the store itself, so the only annotation you write is the pair of typed hooks.

What it still gives you that nothing else does: a serialisable action log, time-travel devtools, and middleware. On a large team, being able to replay exactly what a user did is worth real money.

RTK Query: the server-state half of Redux

const api = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: "/api" }),
  tagTypes: ["Post"],
  endpoints: (build) => ({
    getPosts: build.query({ query: () => "posts", providesTags: ["Post"] }),
    addPost: build.mutation({
      query: (body) => ({ url: "posts", method: "POST", body }),
      invalidatesTags: ["Post"],
    }),
  }),
});

const { data, isLoading } = api.useGetPostsQuery();

If a codebase is already on Redux, this is how it stops hand-writing loading flags: RTK Query ships inside Redux Toolkit and does the job a query cache does — caching, deduplication, and refetching whatever a mutation invalidates. The decision between it and TanStack Query is mostly about whether Redux is already there.

Jotai: atoms

const countAtom = atom(0);
const doubledAtom = atom((get) => get(countAtom) * 2);   // derived

const [count, setCount] = useAtom(countAtom);

A bottom-up model: many small independent pieces rather than one tree, with derived atoms recomputing automatically. It suits state that is fine-grained and interdependent — a canvas editor, a spreadsheet — more than it suits a handful of app-wide flags.

Recoil introduced this model, but Meta archived its repository on 1 January 2025 and it is no longer maintained. Treat Recoil in a codebase as a migration item; Jotai is the usual destination because the concepts map almost one to one.

Comparison

ContextZustandRedux ToolkitJotai
Selectorsnoyesyesatoms are the selector
Boilerplatelowvery lowmoderatelow
Devtools—basicexcellentbasic
Provider neededyesnoyesoptional
Server data built innonoRTK Queryno
Best forrarely-changing valuesmost apps that need a storelarge teams, audit trailsfine-grained derived state

Choosing, in the order to ask

  1. Is it server data? A query cache. Stop here for most of it.
  2. Should a link reproduce it? The URL.
  3. Does only one subtree read it? Local state, lifted as far as needed and no further.
  4. Does it change rarely? Context is fine — theme, locale, the signed-in user.
  5. Is it shared and frequently changing? Now a store. Zustand by default, Redux Toolkit if the team wants its devtools and conventions, Jotai if the state is a graph of derived values.

Keep the store small

The failure mode of every store is the same: it becomes the place where all state goes, including things that should be local, derived, or on the server. A store holding isModalOpen has taken a component's private business and made it global — now anything can open that modal, and finding out what did is a search across the codebase.

Rule of thumb: if only one subtree reads it, it does not belong in the store. And do not store what you can compute — keep items and derive the total in a selector, or the two will eventually disagree.

Persisting

const useCart = create(persist(
  (set) => ({ items: [], add: ... }),
  { name: "cart", version: 2, partialize: (s) => ({ items: s.items }) }
));

Persist deliberately and narrowly. Rehydrating the whole store means old shapes from a previous version of your app arriving in new code — so persist the few fields that matter and version them. Bumping version lets a migrate function convert, or discard, what an older release saved.

Interview answer, one sentence

"The reason to use a store rather than context is selectors — context re-renders every consumer on any change, a store re-renders only what read the changed slice, as long as the selector returns a stable value. But most of what ends up in stores is server state, which belongs in a query cache, so the honest first step is separating those."

02Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 8 testsA store in twenty linesintermediate
  • 5 testsA store outside React, read with useSyncExternalStoreintermediate
All 66 for this level →
I12

Finished reading?

0 of 57 chapters read in this series

→Next stop

  1. I11Previous · I11Forms at scale
  2. I12you are here
  3. Next · I13Auth in ReactProtected routes, token refresh, and the redirect that sends people back where they were.3 min read

01On this page

11 sections · 0%

  1. 01The one thing context cannot do
  2. 02Zustand
  3. 03The selector trap, and useShallow
  4. 04Reading the store outside React
  5. 05Redux Toolkit
  6. 06RTK Query: the server-state half of Redux
  7. 07Jotai: atoms
  8. 08Comparison
  9. 09Choosing, in the order to ask
  10. 10Keep the store small
  11. 11Persisting

[] chapters · t top