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
One principle governs every state decision: put state close to where it is used. Patterns (local, lifted, browser persistent, server persistent) emerge naturally once you feel the friction that breaks the previous pattern. Don't memorize the patterns. Learn to recognize the friction.
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.
Why this actually works
FormStateWrapper moves the useState declaration out of SettingsForm entirely. This is the key: SettingsForm no longer owns state, so it doesn't re-render on keystrokes. Sidebar and Footer are created in SettingsForm's render scope. Because their parent never re-renders, neither do they. It's not the provider boundary that protects them; it's that their parent component is now stable.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
Zustand sidesteps both problems. There's no Provider tree: components subscribe from anywhere in the app. Each subscription takes a selector, so a component only re-renders when the specific slice it reads changes, not when any part of the store updates.
The Decision Framework
Forget Redux vs Context vs Zustand. Those are implementation details. The real decision is: where does this state make sense to live? Every bad state architecture I've seen started with a tool choice, not a friction signal.
I answer this with three concrete 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. Wrong placement = prop drilling or duplication. Selectors solve global state performance. The right pattern makes the next feature easy.
Decision Questions
Decision Signals
This is the core: don't move up a level until you actually feel the pain. Premature abstraction is a waste. Each transition below is triggered by a specific kind of friction. When you see these signals, move. Before then, stay where you are.
- →Two sibling components need to read the same value
- →A parent needs to react to something that happens in a child
- →You find yourself duplicating useState in two places and keeping them in sync
- →The user would be frustrated if a browser refresh cleared the state
- →You want to share a specific view with another person via a link
- →Analytics should capture the actual filter/search combination users are using
- →The dataset is too large to filter/sort client-side (>1k items, or growing)
- →Multiple filters combine in ways that need the server to compute the result
- →The same data is fetched in multiple places and should be cached server-side
- →Data must sync across devices or survive app uninstall