Lifting and colocation: state lives at the lowest common owner
State belongs to the lowest component that needs it. Lift it only to the nearest common owner of its readers; every extra level widens the re-render blast radius. Global-by-default turns each keystroke into an app-wide render; composition via children isolates state instead.
The settings page was the slowest screen in the product, and nobody could explain why: it was a form with nine fields. The profiler told the story in one flame graph — a single keystroke in the “display name” input re-rendered 1,400 components, including the navigation shell, a notifications badge, and a pricing table on a screen the user wasn’t even looking at. The cause was a team convention written down two years earlier: “put state in the app store so anything can reach it later.” Every input’s draft value lived at the root. Each keystroke updated root-level state, and React did exactly what it promises: re-rendered the owner and walked its entire subtree. The fix took an afternoon and deleted code — draft values moved into the form, the form moved its shared bits to the lowest component that actually had both readers, and the keystroke render dropped from 1,400 components to 12. Nothing was memoized. The state just moved to where it belonged.
By the end of this lesson you’ll know exactly where to move state when a keystroke triggers a 1,400-component render — and why the fix deletes code instead of adding memoization.
One owner, props down, events up
React state is local by design: a useState belongs to exactly one component instance, and that instance is the single owner — the only place the value can change. When two components need the same changing value, you do not duplicate it (two copies will drift; the bug report reads “the filter chip says active but the list is unfiltered”). You lift it up: find the lowest component that is an ancestor of every reader — the lowest common owner — move the state there, pass the value down as props, and pass change-handlers down so children can request updates. Data flows down, events flow up, and there is one source of truth per piece of state.
function SearchSection() {
// Lowest common owner of <SearchInput> and <ResultsList>:
// both need `query`, so it lives here — and no higher.
const [query, setQuery] = useState("");
return (
<section>
<SearchInput value={query} onChange={setQuery} />
<ResultsList query={query} />
</section>
);
}
function SearchInput({ value, onChange }) {
// Controlled: this component owns nothing; it reports intent upward.
return <input value={value} onChange={(e) => onChange(e.target.value)} />;
}The key word is lowest. Lifting is not “move it to the top” — it is “move it exactly as high as the nearest shared ancestor and stop.” Each level you lift past that point adds subscribers it never needed.
Blast radius: why height is a performance decision
When you see components re-rendering with no apparent reason, ask: how high does their owning state sit? When state changes, React re-renders the owner component and then, by default, its entire subtree — every descendant gets called again, whether or not it reads the changed value. (Re-render is the cheap render phase, not DOM mutation — but calling 1,400 functions and reconciling their output 60 times while someone types is not free.) The height of a piece of state therefore determines its blast radius: state in a leaf re-renders a leaf; state at the root re-renders the app. This is the mechanism behind the “global by default” anti-pattern — putting everything in a root store or top-level context doesn’t just make code harder to trace, it makes every write an app-wide render and then forces you to fight back with React.memo on hundreds of components. Colocation is the inverse discipline: when you notice only one subtree reads a value, push the state back down into it. Moving state down is a pure win — fewer renders, less prop-threading, and the component becomes deletable as a unit.
▸Why this works
Why does React re-render the whole subtree instead of only components that read the changed state? Because with props, “reads” is not a subscription React can see — any descendant might receive a value derived from that state through arbitrary JavaScript. The only safe assumption after an owner re-renders is that any child’s props may have changed, so React re-runs them and lets reconciliation discard identical output. Subscription-precise updates require an explicit contract — selectors in external stores, which is lesson 3 of this unit. With plain state, your lever is structural: keep the owner low.
Composition: isolating state with the children prop
There is a structural escape hatch that beats memoization for the common case: pass expensive subtrees as children. JSX children are elements — objects created where the JSX is written, not where it is rendered. If a stateful wrapper receives its content via children, the wrapper’s re-render reuses the exact same element objects it was given (its parent, which created them, did not re-render). React sees the identical element reference and bails out of re-rendering that subtree entirely.
function ScrollTracker({ children }) {
const [y, setY] = useState(0); // updates on every scroll tick
// children was created by the parent and hasn't changed —
// same element reference, so React skips re-rendering it.
return <div onScroll={(e) => setY(e.target.scrollTop)}>
<ProgressBar y={y} />
{children}
</div>;
}
// <ScrollTracker><HeavyTable rows={rows} /></ScrollTracker>
// HeavyTable does NOT re-render on scroll.This is state isolation by architecture: high-frequency state (scroll, hover, input drafts) gets a narrow component to live in, and the heavy content passes through it untouched. Tradeoff: lifting and colocating is a refactor every time requirements shift — a value used by one component today gets a second reader tomorrow, and you must move it. That friction is real, and it is the honest price of blast-radius control; teams that refuse to pay it drift back to global-by-default and pay in renders instead. Failure mode: duplicating state instead of lifting (“I’ll just keep a local copy too”) — two owners, eventual divergence, and the class of bug where the UI disagrees with itself.
Two sibling components need to read and update the same filter value. What is the correct move, and why exactly there?
A wrapper holding fast-changing scroll state receives a heavy table via the children prop. Why does the table skip re-rendering when the wrapper's state updates?
A filter value is read and updated by two sibling components on the same page. Where should the single useState live?
- 01Explain the mechanism that links the height of a piece of state to rendering cost, and what 'lowest common owner' means operationally.
- 02Why does passing an expensive subtree as children isolate it from a stateful wrapper's re-renders, and when does this beat React.memo?
React state has exactly one owner, and architecture is deciding how high that owner sits. The default is colocation: state lives in the lowest component that needs it. When siblings share a value, you lift it — find the lowest common ancestor of all readers, move the single useState there, pass the value down as props and intent up as handlers. One source of truth, no duplication, no sync effects. The cost model that makes height matter: a state write re-renders the owner and its entire subtree, because React cannot know which descendants read the value through props — so each level above the lowest common owner adds components that re-render for nothing. Global-by-default is this mistake institutionalized: a root store for draft input values turned one keystroke into a 1,400-component render in the Hook story, and moving state down — not memoizing — cut it to 12. The structural complement is composition: a wrapper that owns fast-changing state and receives heavy content via children re-renders alone, because the children elements were created by the parent and keep the same reference, so React bails out of them entirely. Lifting has an honest cost — state moves when readers change, and you pay that refactor repeatedly — but the alternative is paying in blast radius on every write. Push state down when readers narrow; lift it only when they genuinely spread. Now when you see a profiler flame graph full of components that shouldn’t be re-rendering, your first question is structural: how high does the owning state sit, and how far can it come down?
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.