TL;DR
- The hard problem is not “Redux or Zustand or Context?” It is “who owns this state, who mutates it, and what is the source of truth?”
- I separate server state, URL state, local UI state, workflow state, and cross-tree shared state before I pick any tool.
- Most bad React state architectures come from duplicating server truth, overusing global state, or putting unrelated update frequencies into the same provider.
- Good state management feels boring: explicit ownership, predictable transitions, and small re-render blast radius.
Context
Most frontend state debates are really ownership debates in disguise.
On bigger teams, the recurring failures are predictable:
- remote data copied into client stores for no reason
- giant context providers that re-render half the app
- local component state carrying workflow logic that now spans five screens
- URL-relevant state hidden in memory, which breaks navigation, refresh, and shareability
Before I choose a library, I want to classify the state.
The Approach
1. Split state by responsibility first
This is the model I use:
| State type | Best home | Example |
|---|---|---|
| Server state | fetch/cache layer | products, profile, inventory |
| URL state | router/search params | filters, sort, tab, pagination cursor |
| Local UI state | component | open modal, input text, hover |
| Workflow state | reducer | checkout step, wizard transitions, bulk edit |
| Shared client state | context or store | current tenant, feature flags, live collaboration selection |
If I get this wrong, the tool choice does not save me.
2. Start with local state and widen scope only when the pain is real
This progression is still right most of the time:
- local component state
- lifted state in a common parent
- reducer for structured transitions
- scoped context for cross-branch access
- external store only when the frequency or reach justifies it
type Action =
| { type: "added"; title: string }
| { type: "removed"; id: string }
function todosReducer(state: Array<{ id: string; title: string }>, action: Action) {
switch (action.type) {
case "added":
return [...state, { id: crypto.randomUUID(), title: action.title }]
case "removed":
return state.filter((todo) => todo.id !== action.id)
default:
return state
}
}
I like reducers when:
- multiple events can change the same state
- transitions need to be explicit
- I want logic that is easy to test outside the UI
3. Do not duplicate server state into client-global state without a reason
This is one of the most common staff-level critiques I make.
If a data-fetching layer already owns:
- caching
- revalidation
- retries
- stale/fresh semantics
then copying the same data into a second client store often creates:
- dual source of truth
- stale reads after mutation
- more invalidation code
- harder debugging
I only promote server data into client-global state when the app truly needs a special client-side projection or offline-first workflow that the normal data layer cannot express cleanly.
4. Context is a delivery mechanism, not a free global store
Context is great for low-frequency shared state like:
- theme
- auth session summary
- tenant selection
- feature flags
It gets noisy when I dump high-churn data into one broad provider.
Bad pattern:
<AppContext.Provider value={{ user, theme, cartCount, filters, setFilters }}>
{children}
</AppContext.Provider>
Every provider value change creates a new object, and every consumer gets dragged into the blast radius.
Better pattern:
- split providers by concern
- memoize provider values when appropriate
- move high-frequency shared state to a store with selectors if context churn becomes expensive
5. Put URL-worthy state in the URL
If the user expects refresh, back/forward, or sharing to preserve state, I usually put it in route params or search params:
- search query
- selected tab
- filters
- sort order
- pagination cursor
That keeps state aligned with navigation and avoids the “why did my page reset when I refreshed?” class of bug.
6. External stores earn their keep when update frequency or reach gets high
I reach for an external store when:
- state is shared across distant branches
- update frequency is high
- I need selectors to prevent broad re-renders
- workflow coordination crosses many route-local boundaries
Examples:
- collaborative editing presence state
- workspace-wide selection
- advanced dashboard filter orchestration
- micro-frontend shared client state that should not depend on one subtree staying mounted
The rule is still the same: the store should own the right state, not just more state.
7. Separate workflow state from derived presentation
One of the easiest mistakes is storing too much derived state:
const [items, setItems] = useState<Product[]>([])
const [visibleItems, setVisibleItems] = useState<Product[]>([])
If visibleItems is just items filtered by search and sort, I would rather derive it than synchronize two copies:
const visibleItems = useMemo(() => {
return items
.filter((item) => item.name.toLowerCase().includes(search.toLowerCase()))
.sort(sortProducts)
}, [items, search, sortProducts])
Derived state drift is a silent bug factory.
Trade-offs
- Local state is easy to reason about, but cross-tree coordination gets messy once many branches need the same workflow.
- Context reduces prop drilling, but large high-frequency providers become accidental global state with poor render isolation.
- External stores scale coordination better, but if the ownership model is unclear they only make the confusion more durable.
- URL state improves shareability and navigation semantics, but it forces more discipline around serialization and route design.