I12 · Part 2 · Intermediate
State management libraries
What a store gives you that context cannot: selectors.
11On this page
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
| Context | Zustand | Redux Toolkit | Jotai | |
|---|---|---|---|---|
| Selectors | no | yes | yes | atoms are the selector |
| Boilerplate | low | very low | moderate | low |
| Devtools | — | basic | excellent | basic |
| Provider needed | yes | no | yes | optional |
| Server data built in | no | no | RTK Query | no |
| Best for | rarely-changing values | most apps that need a store | large teams, audit trails | fine-grained derived state |
Choosing, in the order to ask
- Is it server data? A query cache. Stop here for most of it.
- Should a link reproduce it? The URL.
- Does only one subtree read it? Local state, lifted as far as needed and no further.
- Does it change rarely? Context is fine — theme, locale, the signed-in user.
- 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."
Opens in the editor: write it, run it, and check it against real tests.
All 66 for this level →Finished reading?
0 of 57 chapters read in this series