A form with a sticky action bar (Save button at top) and scrollable inputs below needs shared state. You lift it and put it in Context, and then every keystroke re-renders every component inside the Provider, including Sidebar and Footer that never read the form data. That's how "lift state up" goes wrong.
State Architecture
Where does state live and why?
The Problem
The Solution
Put the provider as close as possible to the components that use the state.FormStateWrapper wraps only StickyActionBar and Content. Sidebar and Footer stay outside; they never re-render when you type.
When Narrowing Isn't Enough
Two limitations bite once consumers can't sit under one narrow provider. Tree topology: consumers must be inside the Provider, so if they're far apart you wrap everything in between and intermediate components re-render. Whole-object subscription: calling useContext() subscribes you to the entire context value; any property change re-renders you, even if you only use one field.
The rule
useContext(MyContext), you re-render whenever the context value changes. Destructuring or ignoring parts of the value doesn't help. React doesn't track which properties you use.Workaround: Split contexts
One context per concern so each component only subscribes to what it needs.
Split fixes one problem, not both
memo() on every node in between can help; React 19's Compiler does that automatically. For truly scattered, high-frequency state, Zustand (or Redux) is simpler: no Provider tree, fine-grained subscriptions.Zustand: no Provider, fine-grained subscriptions
React Compiler Impact
React 19 introduces the React Compiler, which automatically applies memo() to every component. This directly addresses one of the two Context problems from above.
memo() prevents child components from re-rendering when a parent re-renders. In React 18, the tree topology workaround required manually wrapping every intermediate component, which is tedious, error-prone, and easy to miss. The Compiler eliminates that chore entirely. (useCallback and useMemo are also handled automatically, but those are about reference stability and computation caching, which is less critical to architecture.) What it cannot fix is the subscription model: all useContext() consumers still re-render when the context value changes. That's where Zustand still wins.
Automatic memo(): No More Cascading Re-Renders
The Win
memo(), so child components skip re-renders when their props haven't changed. In React 18, forgetting a single memo() could cascade re-renders across a whole subtree. Now it's automatic. This narrows exactly when you need Zustand.How memo() Reduces the Need for External State
Without memo(), every parent state change cascades down. This is why teams reached for Zustand even for simple cases, just to move state out of the parent and avoid the cascade entirely. With automatic memo(), that specific pain point disappears.
The nuance
memo() eliminates cascade re-renders from parent state. It does not fix cascade re-renders from Context. A component that calls useContext() still re-renders whenever any part of that context changes. That's the remaining reason to reach for Zustand.React 19 State Architecture Decision Tree
TL;DR: React Compiler Changes (And Doesn't Change)
- ✅ memo() is now automatic. The Compiler applies
memo()to every component, eliminating cascade re-renders from parent state. This is the most architecturally significant change: it removes one of the main reasons teams previously reached for Zustand. - ✅ Context is more viable. Low-frequency updates (auth, theme) work well with Compiler optimization.
- ❌ Zustand still wins for high-frequency updates. Fine-grained subscriptions beat Context, even with Compiler.
- 📊 Measure, don't guess. The Compiler does so much that preemptive optimization often wastes time.
- 🎯 Same decision tree, fewer dependencies. Your state architecture decisions remain the same; you just have fewer manual optimizations to maintain.
The Decision Framework
State architecture isn't about choosing Redux vs Context vs Zustand. Those are implementation details. The real question is: what makes state "belong" somewhere?
I answer this with three questions:
1. Who coordinates this data?
How many components need to read or write this state? If it's one component, keep it local. If it's siblings, lift to parent. If it's across the tree, consider URL or global state.
2. What's the source of truth?
Does this state derive from the backend? The URL? User input? Server state should use React Query. URL state should use searchParams. Only ephemeral UI state belongs in local React state.
For UI state with complex transitions, or multi-step flows where the path depends on previous steps, state machines make impossible states unrepresentable.
3. What's the cost of getting it wrong?
Wrong patterns create technical debt. Too local = prop drilling hell. Too global = performance death. The right pattern makes the next feature easy.
My Mental Model
Decision Matrix
| Pattern | Coordination | Persistence | Boilerplate | Use When |
|---|---|---|---|---|
| Local State | Single component | None | Minimal | Data only needed in one component, no sharing required Ex: Toggle visibility, form input before submission |
| Lifted State | Parent + children | None | Low | Sibling components need to coordinate Ex: Tabs + tab panels, accordion items |
| URL State | Any component | Browser history | Medium | State should be bookmarkable or shareable Ex: Filters, search queries, pagination, selected item |
| Server State | Cross-page | Backend | Medium | Data comes from API, needs caching/syncing Ex: User profile, product catalog, comments |
| Global Client | App-wide | Custom (localStorage, etc.) | High | Complex client-side coordination across many components Ex: Shopping cart, notification system, theme with complex logic |
Key insight
See It In Practice
The framework above tells you how to decide. The deep dive below shows what those decisions look like across five levels of feature complexity, two production scenarios, and a collection of patterns senior engineers still get wrong.
Production Patterns
The form that triggered 47 re-renders
A settings form with a sticky Save button at the top and scrollable inputs below. The form state lived in a Context provider that wrapped the entire layout. On every keystroke, all four layout zones re-rendered, including the sidebar and footer that never read the form data.
StickyActionBar and Content. Sidebar and Footer move outside the provider and stop re-rendering entirely. For high-frequency form state that truly is needed across a scattered tree, move to Zustand with selectors; each component subscribes only to the field it uses.useContext(FormContext) subscribes that component to every change, not just the fields it reads.The URL state that saved a support ticket
A people search feature. Users would type a name, browse results, click into a profile, press back, and land on an empty search page with no filters applied. Every support ticket started with “the search isn't working.”
useState for search query, filters, and pagination. Component state doesn't survive navigation. The back button destroyed the session.useSearchParams. The URL became the source of truth. Back button works. Deep links work. Analytics captures the actual filter state users are applying, not just “search page viewed.”Common Mistakes & Hot Takes
Most filter/search/pagination state should be in the URL, not in useState. URL state is free persistence, free sharing, free back-button support, and free analytics. I've seen teams build elaborate cache-sync logic to restore search results on back-navigation that was solved in ten minutes by moving state to useSearchParams. If the state affects what the user sees and they'd want to share it or return to it, it belongs in the URL.
If you're adding Redux or Zustand before you've felt the pain of prop drilling, you're adding ceremony in advance of the problem. Start local. Lift when it actually hurts. The threshold should be: 'I've been asked to pass this same value through three layers of components that don't use it.' That's when you reach for global state, not when you start a project.
Context is the right tool for low-frequency, wide-access state: theme, locale, auth, feature flags. It's the wrong tool for data that updates on keystrokes, scroll position, or animations. If it updates faster than once per second and multiple components subscribe to it, you want Zustand with selectors, not Context. The difference is fine-grained subscriptions: Zustand re-renders only the components that read the changed slice.
I've seen teams put API responses in Zustand, then manually write cache invalidation logic after mutations, then debug stale data issues for weeks. React Query exists because server state has fundamentally different semantics from client state: it can go stale, it needs revalidation, it should be deduplicated across components. Zustand doesn't model any of that. If it comes from an API, it belongs in React Query. If it's ephemeral UI state, it belongs in useState or Zustand.
A Real Rollout
What it actually looks like to audit a bloated state store — and get a team that's been doing it wrong for two years to change.
Context
React app with Zustand for everything — user preferences, API data, form state, UI flags, notification counts. Six engineers. The store had grown to 47 slices over two years of adding whatever felt convenient. New engineers were told to “put it in the store” as a default answer to any state question.
The problem
Two concurrent bugs. First: stale data after form submissions — API data was in Zustand, mutations updated the store optimistically but didn't re-fetch from the server, so the UI diverged from truth in subtle ways that only showed up hours later. Second: a search field that updated a Zustand slice on every keystroke was causing 12 subscribed components to re-render, making the input visibly laggy on mid-range hardware. Both bugs had the same root cause: the wrong tool for the job.
The call
Audited the store by category — not by slice. Proposed: API data moves to React Query; high-frequency UI state moves local or to URL; only true cross-cutting app state stays in Zustand. Did not do a big-bang migration — migrated one feature's data at a time, starting with the features generating the most stale-data bug reports. The search field moved to useState immediately (local state, no store) — that fixed the lag in one line.
How I ran it
The hardest sell: engineers who'd learned React through Zustand didn't have a mental model for “server state” as a distinct category. Ran a team session with one concrete before/after: the same feature in Zustand (cache invalidation manual, error states manual, background refresh manual) vs. React Query (all three automatic). The stale-data bug we'd been chasing for three weeks fixed itself when the first feature migrated — that demonstration was more persuasive than any architecture talk.
The outcome
Stale-data bug reports dropped significantly after three features migrated to React Query. The Zustand store dropped from 47 slices to 8 (auth, user preferences, UI flags, navigation state). Input lag disappeared when high-frequency state moved local. New engineers onboard faster because the state category question has a clear answer: “Is it from an API? React Query. Is it UI-only? useState. Is it shareable? URL. Is it genuinely app-wide? Zustand.”