Colocation and lifting
Put state as low as it can go — in the only component that reads it — and lift it only to the nearest common ancestor of two components that genuinely share it. Location is your primary lever for re-render width and clarity.
You know useState. The question this lesson answers is the one nobody taught you with it: which component should hold the call? That single decision — where a piece of state lives — sets how much of your tree re-renders when it changes, how many props you have to drill, and whether a future reader can see a feature’s wiring in one place or has to chase it across five files.
There’s a default that quietly wrecks both performance and clarity: lifting everything to the top “just in case someone needs it”. It feels safe. It produces a god component that re-renders the whole page on every keystroke and owns ten unrelated concerns. The senior move is the opposite reflex — push state down until something stops you.
After this lesson you can state the colocation rule — keep state in the lowest component that reads it — and its exception — lift to the nearest common ancestor only when two components genuinely share it; recognise over-lifted state by the wasted re-renders and prop drilling it causes; move a piece of state down to shrink the re-render footprint; lift a truly-shared piece up to the right ancestor (not the top); and name the failure mode of lifting-by-default — an informal global store wearing a component’s name.
Colocate: state belongs in the lowest component that actually reads it. If exactly one subtree consumes a value, that’s where useState goes. Colocation isn’t a tidiness preference — when state lives at component X, every render triggered by that state is scoped to X and its children. Put it one level too high and React re-renders that whole level, including siblings that never touch the value.
// colocated: the open/closed state lives in the only thing that uses it
function FaqItem({ q, a }: { q: string; a: string }) {
const [open, setOpen] = useState(false);
return (
<li>
<button onClick={() => setOpen((o) => !o)}>{q}</button>
{open && <p>{a}</p>}
</li>
);
}Toggling one FAQ item re-renders that item. The list, the page header, the sibling items — untouched. State location is the re-render boundary.
Over-lifted state is the silent default that costs you renders and prop drilling. The reflex “hoist it so it’s available” puts a value far above its only reader. Now React re-renders everything between the owner and the reader on every change, and you thread the value (and its setter) through components that don’t care.
// over-lifted: `draft` lives in the page, but only <Composer> reads it
function InboxPage() {
const [draft, setDraft] = useState(""); // wrong home
return (
<Layout>
<Sidebar /> {/* re-renders on every keystroke */}
<ThreadList /> {/* re-renders on every keystroke */}
<Composer draft={draft} setDraft={setDraft} /> {/* the only real reader */}
</Layout>
);
}Every character typed in <Composer> re-renders <Sidebar> and <ThreadList>. They render the same output every time — pure waste — and draft/setDraft are now part of the page’s surface area for no reason.
The fix is mechanical: move state down to the component that reads it. Delete the useState from the parent, drop the props you were drilling, and declare the state inside the consumer. The re-render footprint collapses to that consumer; the prop drilling disappears.
// colocated: state moved into its only reader
function InboxPage() {
return (
<Layout>
<Sidebar />
<ThreadList />
<Composer /> {/* no props to drill */}
</Layout>
);
}
function Composer() {
const [draft, setDraft] = useState(""); // home is where it's read
return <textarea value={draft} onChange={(e) => setDraft(e.target.value)} />;
}Now typing re-renders only <Composer>. <Sidebar> and <ThreadList> are inert during editing. No memo, no useCallback, no profiler hunt — just the right home for the state. Most “why is this re-rendering” bugs are a colocation bug in disguise, and moving state down fixes them at the source instead of patching symptoms with memoisation.
Lift up only when two components genuinely share state — and only to their nearest common ancestor. When two siblings must read or write the same value (a filter that both a <SearchBox> and a <Results> need), no single child can own it. Hoist it to the closest component that contains both, pass the value down, pass setters down. Closest — not the top of the app.
// shared: SearchBox writes the query, Results reads it → lift to their parent
function ProductSearch() { // nearest common ancestor of both
const [query, setQuery] = useState("");
return (
<>
<SearchBox query={query} onChange={setQuery} />
<Results query={query} />
</>
);
}The rule is symmetric with colocation: as low as possible, but high enough to cover every reader. Lifting to the nearest common ancestor keeps the re-render boundary tight; lifting to the root makes the whole app the boundary — which is exactly the failure mode the next inset names.
Move one piece down, lift another up — same screen, two directions. A settings panel has a live search filter over a list and a “save all” button that needs the full edited form.
Before: everything sits in the panel, “to keep it together”.
function SettingsPanel({ fields }: { fields: Field[] }) {
const [filter, setFilter] = useState(""); // only <FieldList> uses this
const [values, setValues] = useState<FormValues>({}); // <Field>s + Save use this
return (
<>
<Toolbar /> {/* re-renders on every filter keystroke */}
<FieldList filter={filter} onFilter={setFilter} values={values} onChange={setValues} fields={fields} />
<SaveBar values={values} />
</>
);
}Typing in the filter re-renders <Toolbar> and <SaveBar> for nothing, and filter is drilled even though only the list cares. Two separate fixes, in opposite directions:
// 1) filter is read by ONE place → push it DOWN into FieldList
function FieldList({ fields, values, onChange }: FieldListProps) {
const [filter, setFilter] = useState(""); // colocated
const shown = fields.filter((f) => f.label.includes(filter));
return (
<>
<input value={filter} onChange={(e) => setFilter(e.target.value)} />
{shown.map((f) => (
<Field key={f.id} field={f} value={values[f.id]} onChange={onChange} />
))}
</>
);
}
// 2) values is genuinely shared (every Field writes it, SaveBar reads it)
// → it stays LIFTED, but only to SettingsPanel — their nearest common ancestor
function SettingsPanel({ fields }: { fields: Field[] }) {
const [values, setValues] = useState<FormValues>({});
return (
<>
<Toolbar /> {/* now inert while filtering */}
<FieldList fields={fields} values={values} onChange={setValues} />
<SaveBar values={values} />
</>
);
}Now filtering re-renders only <FieldList>; <Toolbar> and <SaveBar> stay put. values is still shared, but it sits at the nearest ancestor of its readers, not higher. The same screen, with each piece of state at its correct height: one moved down to kill wasted renders, one kept up because the share is real.
▸Common mistake
The failure mode of this unit is lifting everything to the top “just in case”. A useState hoisted to the root component, available to all, sounds flexible. What you’ve actually built is an informal global store with none of a store’s guarantees: every update re-renders the whole tree, the root accretes a dozen unrelated useStates until it’s a god component no one can read, and props get drilled through layers that exist only to pass them along. “Available everywhere” is the symptom of state that has no real home — and the cure is almost always to push it down to the one place that reads it, not to formalise the god component with Context or Redux.
▸Why this works
Why is moving state down a better first move than memo/useCallback? Because colocation removes the wasted render; memoisation only skips it, and at a price. To stop <Sidebar> re-rendering you’d wrap it in React.memo, then stabilise every prop and callback it receives with useMemo/useCallback, then keep that stable as the code changes — a standing tax on every edit. Relocating draft into <Composer> deletes the problem: there’s no render to skip because the state never reaches <Sidebar>. Reach for memo when a component is genuinely shared and expensive and already at its lowest home; reach for colocation first.
A search input's text is stored in useState in the top-level <App>, but only <ResultsList> three levels down reads it. Typing lags because the whole page re-renders per keystroke. What's the senior fix?
State location is your primary lever for both performance and clarity. The rule is two-sided: colocate — keep each piece of state in the lowest component that reads it, so the re-render boundary is as tight as possible and nothing is drilled; and lift — only when two components genuinely share a value, and only to their nearest common ancestor, never higher. Over-lifted state is the silent default: it re-renders siblings that ignore the value and drills props through layers that don’t care, and most “why is this re-rendering?” bugs are this in disguise — fixed by moving state down, not by sprinkling memo. The failure mode to refuse is lifting-by-default: hoisting every useState to the top produces a god component that’s an informal global store with all of the re-renders and none of the guarantees. Push state down until a real share stops you; then lift just far enough to cover its readers.
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.