renderpx
Theme: auto

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

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.

Type email: Sidebar & Footer re-render unnecessarily
Provider wraps all 4 components. Type email → all 4 re-render (Sidebar & Footer wasted).
Settings
Renders: 1
Edit profileRenders: 1
(scroll down to see more fields)
Changes auto-saveRenders: 1
Problemtsx
Loading...

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.
Type email: only sticky bar & form re-render
Provider wraps only sticky bar & form → Sidebar & Footer never re-render.
Settings
Renders: 1
Edit profileRenders: 1
(scroll down to see more fields)
Changes auto-saveRenders: 1
Narrow the providertsx
Loading...

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

If you call 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.

Change sort or filter: both controls re-render
Category (Filter): 1 renders
Sort by (Sort): 1 renders
Products: 1 renders
Chair - $150
Desk - $300
Keyboard - $80
Laptop - $1200
Monitor - $350
Mouse - $25
Problemtsx
Loading...
Change sort or filter: only the relevant control re-renders
Category (Filter): 1 renders
Sort by (Sort): 1 renders
Products: 1 renders
Chair - $150
Desk - $300
Keyboard - $80
Laptop - $1200
Monitor - $350
Mouse - $25
Split contextstsx
Loading...

Split fixes one problem, not both

Splitting fixes the "whole object" issue. It doesn't fix topology: if consumers are scattered, you still wrap a large tree. 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.

Type email: only StickyBar & inputs re-render
Zustand: Only StickyBar & Inputs re-render on type. Sidebar & Footer stay at 1.
Settings
Renders: 1
Edit profileRenders: 1
(scroll down to see more fields)
Changes auto-saveRenders: 1
Zustandtsx
Loading...

Deep Dive: State Management Internals →

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

Ask three things about each piece of state: (1) How long does it live? Just this render, this session, or forever? (2) Who needs it? One component or many? (3) Does it need to persist? Across page refreshes or devices? Form input = lives briefly, one component = local state. Filters = live until changed, affect data fetching = URL or global. Cart = depends on whether you want to remember it across sessions.

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.

LocalLifted
Move up when you see
  • 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
Don't move yet if: One level of prop passing is fine. Lifting is cheap - don't reach for global state to avoid it.
LiftedBrowser Persistent
Move up when you see
  • 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
Don't move yet if: State that's purely transient (a dropdown open/closed) doesn't need persistence. Choose URL for shareability, localStorage for silent persistence.
Browser PersistentServer Persistent
Move up when you see
  • 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
Don't move yet if: Browser persistence (URL, localStorage) is faster to implement and adequate for user-specific filters or preferences that don't need cross-device sync.

Related Patterns

Related Deep Dives