Pushing State Down

March 20, 20255 min read

Before useMemo. Before useCallback. Before React.memo. There is one technique that is more effective than all of them combined — and it doesn't require any API.

Move the state closer to where it's used.

The Rule

State should live as high as you need it, and as low as you can get away with.

The first part is a constraint: if two sibling subtrees both need the same state, the state has to live in their common ancestor. You can't escape that.

The second part is the opportunity: if state only affects a single subtree, don't let it live above that subtree. Every component between the state and its actual consumer will re-render on every change, for no reason.

The Problem

State lifted higher than it needs to be causes the entire subtree below it to re-render on every change.

TSX
// ❌ All three panels re-render whenever the search input changes function Page() { const [query, setQuery] = useState(''); return ( <div> <SearchBar query={query} onChange={setQuery} /> <ResultsPanel query={query} /> {/* needs query — fine */} <RecommendedPanel /> {/* doesn't need query — unnecessary re-render */} <UserProfilePanel /> {/* doesn't need query — unnecessary re-render */} </div> ); }

Every keystroke in the search box triggers a re-render of RecommendedPanel and UserProfilePanel. They have nothing to do with the search state.

The Fix: Colocate State

Extract the components that actually need the state into their own subtree.

TSX
// ✅ Only SearchSection re-renders on input change function Page() { return ( <div> <SearchSection /> {/* owns the query state */} <RecommendedPanel /> {/* untouched */} <UserProfilePanel /> {/* untouched */} </div> ); } function SearchSection() { const [query, setQuery] = useState(''); return ( <> <SearchBar query={query} onChange={setQuery} /> <ResultsPanel query={query} /> </> ); }

SearchSection now owns query. When it changes, only SearchSection and its children re-render. RecommendedPanel and UserProfilePanel are completely isolated.

A root component that renders a series of self-contained components with no props passed down is a healthy sign. Each subtree is responsible for itself.

The Numbers in Context

After this refactor in a real app, you might see render times drop from something like 8ms per keystroke to 1.3ms — with only the input component rendering instead of the whole page.

The browser targets 60fps — a 16.6ms frame budget per render. A 1.3ms render leaves 15ms to spare. At that point stop optimising. Re-renders aren't bad. Unnecessary re-renders are bad. Fast unnecessary re-renders are merely cosmetically annoying — your eyes can't see anything that fits in a single frame.

When State Belongs Higher: The Current User Exception

Some state genuinely needs to live high in the tree — and that's fine. The current user is the classic example:

  • Almost every component needs it (navigation, settings, permissions, display names)
  • It almost never changes during the session

Both properties together make it okay to live at the top. Yes, if it changes, everything re-renders. But it almost never changes — and when it does, everything probably should update.

The rule isn't "state should never be at the top." It's "state should only be at the top if things at the top actually need it."

The Children Prop Escape Hatch

Sometimes you can't restructure the component tree. A stateful wrapper sits in the middle and you can't move the expensive component out.

The children prop is your escape hatch.

TSX
// ❌ ExpensiveSummary re-renders whenever interval changes (it's a child) function DataDashboard() { const [interval, setInterval] = useState('1d'); return ( <div> <IntervalPicker value={interval} onChange={setInterval} /> <ExpensiveChart interval={interval} /> <ExpensiveSummary /> {/* doesn't need interval — unnecessary re-render */} </div> ); } // ✅ ExpensiveSummary passed as children — created outside IntervalSection function DataDashboard() { return ( <IntervalSection> <ExpensiveSummary /> </IntervalSection> ); } function IntervalSection({ children }) { const [interval, setInterval] = useState('1d'); return ( <div> <IntervalPicker value={interval} onChange={setInterval} /> <ExpensiveChart interval={interval} /> {children} {/* same element reference from parent — React skips re-render */} </div> ); }

ExpensiveSummary is created as a React element in DataDashboard. When IntervalSection re-renders, children is just a prop — the same reference DataDashboard already created. React sees no change and skips the render.

The Hidden Problem: New Function References

Pushing state down often reveals a second problem. After the refactor, your component might look like this:

TSX
function Counter() { const [count, setCount] = useState(0); const handleIncrement = () => setCount(c => c + 1); // new function every render const handleReset = () => setCount(0); // new function every render return <CounterButtons onIncrement={handleIncrement} onReset={handleReset} />; }

These handler functions are redefined on every render. Even if they do the same thing as last render, they're different objects in memory — so any React.memo on CounterButtons won't help. The props are technically different on every render.

This is why useCallback exists: to stabilise function references across renders. State colocation gets you most of the way there. useCallback handles the rest.

When Colocation Isn't Enough

State colocation breaks down when:

  • Multiple disconnected subtrees need the same state
  • State needs to survive unmounting (e.g., preserved filters when navigating away)
  • State is truly global (auth, theme, cart)

In those cases, context or a state manager is appropriate. But even then — structure the context carefully so changes in one part don't force re-renders in unrelated consumers.