Choosing where state lives
- Choose the smallest home for state: lift to a common parent first, and use the URL for shareable state
- Spot fetched data that belongs in a useQuery cache, not in useState or a client store
- Predict that every Context consumer re-renders on a new value, and explain how store selectors avoid it
- Fix a provider's fresh value object with useMemo and useCallback so unrelated updates skip consumers
Read
State has a home — climb the ladder only as far as you need
FlagMost "state management" problems are really placement problems: the state is fine, it's living in the wrong place. Before reaching for a library, ask two questions, then walk a ladder.
First, does something outside your components already own it?
- Did it come from a server? (users, orders, this page's content) → a server-state library (TanStack Query / SWR), however few components read it. It's a cache of someone else's data, with its own rules: loading, caching, staleness, invalidation.
- Should a link reproduce it? (filters, tabs, pagination, the currently-open item) → the
URL (
searchParams). It's shareable, bookmarkable, and survives a refresh.
If both answers are no, it's state your app invents. Climb only as far as you need and stop at the first rung that works:
- Local (
useState/useReducer) — only one component cares. This is the default. - Lifted — a couple of nearby components share it → move it to their nearest common parent and pass it down.
- Context — many components across the tree need the same, rarely-changing value (theme, locale). Context is a transport, not a store: it can also carry a value something else owns, such as the signed-in user your query cache fetched.
- Client store (Zustand / Redux) — app-wide client state that changes often and is read in many places, where you want components to subscribe to slices.
The mistakes that cause "state hell" are almost always one of two: skipping rungs —
reaching for a global store when lifting would do — or skipping the two questions —
stuffing server data into useState or a client store and re-implementing caching by hand.
Rule of thumb: put state at the smallest scope that still lets everything that needs it reach it — unless the server or the URL already owns it.
Context vs client store vs server state — what each is for
FlagThese three get conflated constantly. They solve different problems.
Context is a transport, not a state manager. It moves a value down the tree without
prop-drilling. It has no notion of "subscribe to part of the value": every consumer
re-renders whenever the provider's value changes identity. That's perfect for values that
change rarely (theme, auth user, locale) and a trap for values that change often.
A client store (Zustand) adds selector subscriptions. You read a slice, and your component re-renders only when that slice changes:
// re-renders only when `theme` changes — not on every store update
const theme = useAppStore((s) => s.theme);
Reach for it when app-wide client state changes frequently and is read widely, or when Context's "re-render everyone" cost starts to bite.
Server state is a different animal — use a server-state library. Data you fetched (users, orders, this page's content) is a cache of the server's data. It goes stale, needs refetching, deduping, retries, and invalidation. TanStack Query gives you all of that (this track uses v5):
const { data, isPending } = useQuery({ queryKey: ["users"], queryFn: fetchUsers });
isPending is the v5 name for "no data yet"; v4 called it isLoading, and in v5
isLoading means isPending && isFetching. v5 also accepts only this single-object form of
useQuery.
Putting server data in useState or Zustand means hand-rolling caching and invalidation —
badly. The tell: if you've written a useEffect that fetches and stores into state, you're
building a worse useQuery.
Quick map:
- Rarely-changing, tree-wide → Context
- Often-changing, app-wide client state → client store with selectors
- Remote data → server-state library
- Shareable / refresh-surviving → URL
The trade-offs that decide it — and the anti-patterns
FlagThe ladder gets you 90% there. The last 10% is knowing the sharp edges.
Context's re-render cost is real and non-obvious. A consumer re-renders when the context
value changes by identity — even if it only reads one field, even if it's wrapped in
memo. Two consequences:
- An inline
value={{ user, logout }}creates a new object every provider render → all consumers re-render every time. Memoize it:useMemo(() => ({ user, logout }), [user, logout])(and stabilize the functions withuseCallback). This is the exact bug in this unit's challenge. If your build runs React Compiler (stable since v1.0, October 2025), it inserts this memoization for you; without it — as in the challenge sandbox — you write it by hand. - For a value that does change often, memoizing won't save you — the identity legitimately changes. The fixes are splitting the context (a stable "dispatch" context separate from the changing "state" context, so components that only dispatch don't re-render) or moving to a store with selectors.
Quiz
Which of these belongs in a server-state library (TanStack Query) rather than a global client store like Zustand or Redux?
FlagYou store a rapidly-changing value (say, the current playback time) in a Context that wraps half the app. What happens on each tick?
FlagTwo components three levels apart need to share one piece of state. What's the first thing to reach for?
FlagWhat is the main practical reason to choose a client store like Zustand over Context for frequently-changing, app-wide client state?
FlagFill in the blank
Remote data belongs in a server-state cache, not useState. Complete the TanStack Query call.
Flagconst { data } = ({ queryKey: ["users"], queryFn: fetchUsers });
Stop this provider from re-rendering every consumer on each render by giving the value a stable identity.
Flagconst value = (() => ({ user, logout }), [user, logout]); return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
A search filter should be shareable via a link and survive a refresh, so it belongs in the URL. Read it in a Next.js App Router client component.
Flagconst q = ().get("q");
Predict the output
const Ctx = createContext(null);
function Provider({ children }) {
const [count, setCount] = useState(0);
const [name, setName] = useState("Ana");
return (
<Ctx.Provider value={{ count, setCount, name, setName }}>
{children}
</Ctx.Provider>
);
}
function CountLabel() {
const { count } = useContext(Ctx);
console.log("CountLabel render");
return <span>{count}</span>;
}Challenge
Stop a Context value from re-rendering every consumer
FlagThemeLabel is wrapped in React.memo and reads only the theme from context. It should
re-render only when the theme changes. But every time you click Tick in Settings — an
update that has nothing to do with the theme — the label's render count climbs anyway.
The reason: Settings builds a fresh value object on every render (and a fresh
toggleTheme function). React Context compares its value by identity, so when Settings
re-renders for the tick, the context value looks "new" and every consumer re-renders —
memo can't help, because the change comes through context, not props.
Your task
Edit Settings.tsx so the tests pass:
- The consumer renders once on mount (render count shows 1)
- Clicking Tick does not re-render the consumer (render count stays 1)
- Clicking Toggle theme still flips the theme (
light→dark)