Why did this component render?
Nothing it displays changed. It rendered anyway. Four causes, and only one of them is a bug.
- Level
- DEVELOPER
- Read time
- 10 min
- Experiment
- Available
- Type
- SIMULATION
The question
A leaf component re-renders on every keystroke in an unrelated input. Its props look identical. What actually triggers a render?
Hypothesis
A component renders when its own state changes, when its context value changes, or when its parent renders, and by default, the last one does not care whether props changed.
Method
Laboratory available
The instrument for this investigation runs in the laboratory, where the controls, the live model and the observation log share one workstation.
Enter the laboratoryChange state at different points in the tree and watch the render wave travel. Toggle memo and stable references to see where the wave stops.
What we observed
| Trigger | What re-renders |
|---|---|
| setState in a component | That component and its entire subtree |
| Parent re-renders | All children, memoised or not, unless memo + equal props |
| Context value changes | Every consumer of that context, wherever it sits |
| New key on an element | Old instance unmounts, new one mounts with fresh state |
| Ref changes | Nothing, refs do not trigger renders |
Why it happens
The usual culprit is a new reference created during render. Objects, arrays, inline functions and JSX elements are fresh values on every call, so a memo'd child that receives one is never actually memoised.
// New object every render<Chart options={{ animate: true }} />// New function every render<Row onSelect={() => select(row.id)} />// New array every render<List items={data.filter(Boolean)} />// Stable alternativesconst options = useMemo(() => ({ animate: true }), []);const onSelect = useCallback((id) => select(id), [select]);const items = useMemo(() => data.filter(Boolean), [data]);The structural fix is usually better than the memo fix. If the state that changes on every keystroke lives above a large subtree, moving that state *down* into the component that uses it removes the render wave entirely, no memo, no dependency arrays, no stale-closure bugs.
// Every keystroke re-renders <ExpensiveTree />function Page() { const [q, setQ] = useState(""); return ( <> <input value={q} onChange={(e) => setQ(e.target.value)} /> <ExpensiveTree /> </> );}// The subtree is passed as children, so it is not re-createdfunction Page() { return ( <SearchBox> <ExpensiveTree /> </SearchBox> );}Why does React re-render children by default?
Because it cannot know whether a child's output depends on something it cannot see.
Further research
In development, React's StrictMode intentionally double-invokes component bodies, initialisers and effects to surface impure renders. It does not do this in production. A component that behaves differently under StrictMode has a side effect in its render path.
References
- 01React docsRender and commit
- 02React docsmemo
- 03React docsReact Compiler