skip to content

A React screen keeps every piece of state in the top-level page component, including a search string used only by one deeply nested table. What does that placement cost, and what rule decides where a piece of state actually belongs?

level: middleimportance: should knowfreq 56%

answer

  1. push state down, not up
  2. who reads it, who writes it
  3. re-render blast radius starts at the owner
  4. state lives as long as its owner
  5. lift only under pressure

basics

~20 s

State belongs in the lowest component that needs it. Hoisting a table's search string to the page re-renders the whole screen on every keystroke, piles unrelated concerns into one component, and stops the table from being usable on its own.

solid answer

~50 s

The rule is colocation: a piece of state lives in the lowest component that reads or writes it, and you lift it only when a second consumer appears — and then only to their nearest common ancestor. Putting the table's search string on the page violates that in three ways. Every keystroke sets state on the page, so React re-renders that whole subtree instead of the one component that cares. The page component accumulates a dozen unrelated `useState` calls and becomes the file nobody wants to touch. And the table can no longer be dropped anywhere else, because it only works when some ancestor supplies `search` and `onSearchChange`. Colocating pushes the state back into the table, which also means the string disappears when the table unmounts — usually exactly what you want, since it is UI state belonging to that view.

code

javascript · 24 lines
javascript
import { useState } from 'react';

// Colocated: the search string belongs to the only component that uses it.
export function ResultsTable({ rows }) {
  const [search, setSearch] = useState('');
  const visible = rows.filter((row) =>
    row.name.toLowerCase().includes(search.toLowerCase()),
  );

  return (
    <div>
      <input
        value={search}
        onChange={(event) => setSearch(event.target.value)}
        aria-label="Filter rows"
      />
      <ul>
        {visible.map((row) => (
          <li key={row.id}>{row.name}</li>
        ))}
      </ul>
    </div>
  );
}

go deeper

for a junior

Know the phrasing: state goes in the component that uses it, and you move it up only when something else needs it too. Be able to point at a page-level useState that only one nested component reads and say it belongs lower.

for a middle

Explain the mechanics behind the rule — the setter marks its owning component for re-render, so the owner defines the blast radius — and give the non-performance reasons too: readability, reusability, and the fact that state is discarded when its owner unmounts.

for a senior

Diagnose it in real code. Be ready to say that a screen which stutters while typing is usually a placement problem before it is a memoization problem, and to weigh the deliberate exception where higher placement is chosen for lifetime rather than sharing.

for a principal

Frame state placement as an architectural boundary question: which component owns which slice, what the review signal for over-lifting is, and how you keep screen-level components from becoming the default dumping ground as a codebase and its team grow.

## The default is down, not up Most teams get the direction of the rule backwards. "Lifting state up" is a *repair* you apply when a second component needs the value; it is not the starting position. The starting position is **colocation**: state is declared in the component that uses it, and it moves upward only under pressure — one level at a time, to the nearest common ancestor of its actual consumers. A page component holding a search string that only a nested table reads has been lifted for no reason. Nothing forced it up there; it drifted up because someone was already editing the page component when they added the feature. ## Cost 1 — the render blast radius Calling a state setter marks the component that owns the state for re-render, and React then walks its subtree. State that lives at the top of the screen makes the top of the screen the re-render root, so a keystroke in one input causes React to re-render the header, the sidebar and every sibling panel, even though the value they receive is unchanged. Moving the state into the table shrinks the blast radius to the table. That is a structural fix, not a memoization one — no `memo` and no `useMemo` involved. It is why "colocate state" is the first advice for a screen that feels sluggish while typing, and why memoizing everything is the second-best answer to a problem that better placement removes entirely. ## Cost 2 — the component becomes a junk drawer A page holding ten unrelated `useState` calls is hard to read for a reason that has nothing to do with performance: nothing in the file tells you which piece of state belongs to which part of the UI. Deleting a feature means hunting for the state it left behind. Reviewing a change means checking whether a setter is used somewhere three files away. Colocated state is self-documenting — the state and the markup that depends on it are in the same 40 lines. ## Cost 3 — the child stops being reusable and testable When the table declares its own search state, it is a component you can render anywhere: `<ResultsTable rows={rows} />`. When the state is lifted, the table's contract grows to `<ResultsTable rows={rows} search={search} onSearchChange={fn} />`, and every new caller must supply a value it does not care about. The same applies to tests: colocated state means the test renders the component and types into it, rather than building a wrapper that owns state on its behalf. ## Cost 4 — the state outlives the UI This one is subtle and often the real bug. State lives as long as its owning component stays mounted at the same position. If the search string lives on the page and the table unmounts when the user switches to another tab, the string survives; coming back to a tab you expected to be fresh, the old filter is still applied. Colocated in the table, the state is created when the table mounts and discarded when it unmounts, which matches what users expect from ephemeral view state. ```jsx function ResultsTable({ rows }) { const [search, setSearch] = useState(''); // lives and dies with the table const visible = rows.filter((r) => r.name.includes(search)); return ( <> <input value={search} onChange={(e) => setSearch(e.target.value)} /> <Rows rows={visible} /> </> ); } ``` ## The decision procedure For any piece of state, ask which components read it and which write it, then place it at the lowest component that renders all of them: 1. One component reads and writes it → it lives there. Done. 2. Two or more → their nearest common ancestor, which passes the value down and callbacks up. 3. The value must survive the component's unmount (a filter that should persist across tab switches, a draft you must not lose) → that is a real requirement for placing it higher, and it is a different reason from "two components need it". Say so explicitly rather than hiding it. 4. The value belongs to the server rather than the UI → placement inside the component tree is the wrong question in the first place. ## When lifting really is correct Do not over-rotate. If the toolbar above the table and the table both need the search string, the state belongs to their common parent and there is nothing to apologise for — the URL query string is another legitimate owner when the filter should be shareable and survive a reload. The defect is state sitting *above* every component that uses it, not state sitting at the level its consumers require. ## What interviewers listen for That you name the rule as "lowest component that needs it" and can give more than one reason for it. Candidates who only say "performance" have half the answer; the maintainability, reusability and lifetime arguments are what separate a memorised rule from an internalised one.

  • Is colocation only a performance argument?
    No, and treating it as one is the weak answer. The narrower re-render is one benefit; the others are that the component becomes readable because state sits next to the markup that uses it, reusable because its props no longer demand values callers do not care about, testable in isolation, and correct about lifetime — the state is discarded when the UI that owns it unmounts.
  • When is placing state above every consumer actually the right call?
    When the lifetime requirement demands it: the value must survive the consumer unmounting — a filter that should persist while the user visits another tab, or a wizard draft spanning steps. That is a deliberate decision about lifetime, not a default. State that should survive a reload or be shareable usually belongs in the URL rather than in a higher component.
  • How do you spot over-lifted state in an existing codebase?
    Look for a component whose state setter is never called in its own JSX, only passed down; for props threaded through layers that never read them; and for a screen-level component with many unrelated useState calls. Each is a signal that the value can be pushed down to the component that actually uses it.

saying these in an interview costs you the question

  • Says all page state should live at the top for consistency
  • Fixes a slow screen with memo instead of moving state down
  • Thinks colocation is purely a performance trick
  • Cannot say which components must read or write the value
  • Believes state persists after its owner unmounts

context