In a React page component, a search input's value is held in state at the top of the page, and every keystroke re-renders the whole page including a large results table. Describe the structural fix that removes the per-keystroke cost without React.memo or useMemo.
answer
- state placement decides render scope
- the update is fine, its owner is too big
- split draft from committed value
- shrink the subtree, do not cache it
- one small component owns the fast state
basics
~20 sMove the state down: extract the input and its useState into a small child component, so a keystroke re-renders only that child instead of the whole page. Lift only the committed or debounced value back up if the table truly needs it.
solid answer
~50 sA state update re-renders the component that owns the state plus everything it renders, so state placed high makes every keystroke expensive by definition. The fix is to shrink that subtree rather than cache around it: pull the input and its `useState` into a `SearchInput` component, and the page stops re-rendering entirely while the user types. If the table genuinely needs the text, split draft state from committed state — the child keeps the per-keystroke value locally and calls up only on submit or after a debounce, so the table re-renders once per search instead of once per character. If the page must stay the state owner for other reasons, the complementary move is to have it accept the table as `children` so that subtree keeps its identity. Both are structural; neither adds a cache to maintain.
code
jsx · 25 linesimport { useState } from 'react';
function SearchInput({ onSearch }) {
const [query, setQuery] = useState('');
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') onSearch(query); }}
/>
);
}
export function SearchPage({ rows, onSearch }) {
return (
<div>
<SearchInput onSearch={onSearch} />
<ResultsTable rows={rows} />
</div>
);
}
function ResultsTable({ rows }) {
return <ul>{rows.map(r => <li key={r.id}>{r.label}</li>)}</ul>;
}go deeper
Remember the rule of thumb: put state in the smallest component that reads it. Being able to say "the input's value belongs to the input" already answers most of this question.
Explain why placement is the cost: an update re-renders its owner and that owner's subtree, so a smaller owner means less work. Show the draft-versus-committed split when the value is needed elsewhere.
Demonstrate the diagnosis, not just the fix — identify from a profile which state owner is dragging the expensive subtree along, and choose between moving state down and relaying the subtree as content when it cannot move.
Frame it as a design rule the codebase enforces: fast-changing state never sits above expensive regions, and page-level components hold routing and committed values only. That keeps performance work out of individual feature reviews.
## The cost model in one sentence When a component's state changes, React re-renders that component and, by default, the components it renders — down the tree until something bails out. So **where the state lives determines how much work an update costs**. Nothing about the input is expensive; putting its state at the page level is what makes each keystroke expensive. ```jsx // Before: every keystroke re-renders ResultsTable and everything else on the page function SearchPage({ rows }) { const [query, setQuery] = useState(''); return ( <div> <input value={query} onChange={e => setQuery(e.target.value)} /> <Filters /> <ResultsTable rows={rows} /> </div> ); } ``` ## The fix: give the fast-changing state its own component Extract the input together with the state that only it reads. ```jsx function SearchInput({ onSearch }) { const [query, setQuery] = useState(''); return ( <input value={query} onChange={e => setQuery(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') onSearch(query); }} /> ); } function SearchPage({ rows, onSearch }) { return ( <div> <SearchInput onSearch={onSearch} /> <Filters /> <ResultsTable rows={rows} /> </div> ); } ``` Now `setQuery` re-renders exactly one component that renders one DOM node. `SearchPage`, `Filters` and `ResultsTable` are not in the state owner's subtree any more, so React never visits them. The expensive work was not made faster — it was made *unnecessary*. ## Draft state versus committed state The obvious objection is "but the table filters as you type". Handle it by recognising two different pieces of state that people usually collapse into one: - the **draft** — what is in the box right now, changing 60 times a search, read only by the input; - the **committed** value — what the results actually reflect, changing once per submit or once per debounce interval, read by the table. Keep the draft in the child and lift only the committed value. The table then re-renders once per search instead of once per character, which is the real target: you are reducing how often the expensive subtree renders, not just how fast it renders. ## When the state genuinely has to stay high Sometimes several siblings need the same live value and there is no lower home for it. Two structural moves still apply before you reach for a cache: 1. **Split the component.** A page that owns three unrelated pieces of state is three render boundaries fused into one; separating them means each update touches only its own region. 2. **Relay the heavy subtree as content.** Have the state owner accept the table via `children` (or an element-valued prop) from a parent that is not re-rendering. The relayed element keeps its identity across the owner's state updates, so React bails out on it. ## Why this is preferred to memoization A `React.memo` around the table would also stop the re-render, but it is a cache with preconditions: every prop must stay referentially stable, so one inline `style={{...}}` or arrow callback added months later silently disables it, with no error and no test failure. Moving the state down has no precondition. It cannot be accidentally undone by a prop change, it needs no dependency array, and the next reader sees a small component that plainly owns a small piece of state. ## Pitfalls when applying it - **Do not split so finely that the value ping-pongs.** If the child must be told its own value back from the parent on every keystroke, you have re-created the original coupling with extra indirection. - **Watch for a remount.** If the extracted component is rendered conditionally or given a changing `key`, it will lose its state; keep it in a stable position. - **Confirm, do not assume.** Record a profile before and after; the goal is fewer components rendered per commit, and if that number does not drop, the state did not actually move out of the expensive subtree. - **The parent still re-renders sometimes.** Extracting the input does not freeze the page; it means the page renders when *its own* concerns change, not on every character.
- After the extraction, does the page component now never re-render?No — it still re-renders when its own state or props change, including when the child lifts a committed search value up. The win is narrower than "the page is frozen": per-keystroke updates no longer cross the boundary, so the expensive subtree renders once per search instead of once per character.
- The table has to filter live as the user types, with no submit button. What now?Keep the draft in the child and lift a debounced or deferred value, so the table renders on a slower cadence than the input. The structural point survives: the fast-changing state stays in the small component, and only the value the table actually needs travels upward.
- How would you verify the split actually reduced work?Record a commit while typing and compare how many components React rendered per keystroke before and after. If the table still appears in the commit, the state did not truly leave its subtree — usually because the value is still being relayed back down on every character.
saying these in an interview costs you the question
- Reaches for React.memo before asking where the state lives
- Says debouncing the setState fixes it, though the scope is unchanged
- Thinks React re-renders from the root on every state update
- Collapses draft and committed values into one piece of state
- Claims moving state down means the parent never re-renders again