React Re-renders: The Mental Model That Fixed My Performance Problems
I spent a year sprinkling useMemo on things. Then I learned what actually triggers a re-render, and deleted most of it.
For a long time my approach to React performance was superstition. Something felt slow, so I'd wrap a function in useCallback, wrap a value in useMemo, wrap a component in memo, and move on. Sometimes it helped. I couldn't have told you why.
The model that fixed this is four sentences long.
The model
- 01A component re-renders when its state changes, when its context value changes, or when its parent re-renders.
- 02That third one is unconditional: a parent rendering re-renders all its children, regardless of whether their props changed.
- 03
memobreaks rule 2 — a memoised component skips the render if its props are shallowly equal. - 04Re-rendering is not the same as touching the DOM. React diffs the output; if nothing changed, no DOM work happens.
Rule 4 is the one that reframes everything. A re-render of a simple component is cheap — it's a function call and an object comparison. Optimising away cheap re-renders is how you end up with a codebase where every function is wrapped in useCallback and nothing is measurably faster.
So when does it actually matter?
Three situations, in my experience:
- The render itself is expensive. A chart that computes layout, a table that formats 500 rows, a component running a heavy filter over a large array.
- The subtree is large. A cheap render times 2,000 components is not cheap.
- The render happens very often. Anything driven by scroll, mouse move, or a controlled input — 60 times a second turns a 2ms render into a dropped frame.
If none of those apply, leave it alone. Seriously. The useMemo you're about to write costs a dependency array you have to keep correct, and it will drift.
The fix that beats memoisation: move state down
Most re-render problems are state that lives too high. Here's the shape of it:
// Every keystroke re-renders the entire dashboard
function Dashboard() {
const [search, setSearch] = useState('');
return (
<>
<input value={search} onChange={(e) => setSearch(e.target.value)} />
<ExpensiveChart />
<HugeTable />
</>
);
}// Keystrokes now re-render one small component
function SearchBox() {
const [search, setSearch] = useState('');
return <input value={search} onChange={(e) => setSearch(e.target.value)} />;
}
function Dashboard() {
return (
<>
<SearchBox />
<ExpensiveChart />
<HugeTable />
</>
);
}No memo, no useCallback, no dependency arrays. The state is scoped to what it affects. This is the first thing to try and it works far more often than people expect.
The other structural fix: children as props
When state genuinely has to live high up, you can still keep expensive subtrees out of the re-render by passing them as children. Children are created by the *parent's* parent, so they keep the same element reference across the stateful component's renders:
function ThemeShell({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState('light');
return (
<div className={theme}>
<ThemeToggle onToggle={setTheme} />
{children} {/* unchanged element — not re-rendered */}
</div>
);
}
<ThemeShell>
<ExpensiveChart />
</ThemeShell>Context is a broadcast, and that's the catch
Every consumer of a context re-renders when the context value changes — regardless of memo, and regardless of whether it reads the part that changed. Two consequences:
- Never pass a fresh object literal as a provider value.
value={{ user, setUser }}creates a new object every render and defeats the entire mechanism. - Split contexts by change frequency. A
UserContextthat changes on login and aMousePositionContextthat changes 60 times a second should not be the same context, no matter how convenient one provider looks.
The React Compiler changes the arithmetic
With the compiler enabled, memoisation is inserted automatically at build time — the compiler sees which values are actually reused and wraps them for you. Manual useMemo/useCallback largely becomes unnecessary.
What it does *not* do is fix architecture. State in the wrong place is still state in the wrong place; a context that broadcasts too widely still broadcasts too widely. The compiler removes the tedious layer of this problem, not the design layer.
Measure with the profiler, not with vibes
React DevTools has a "Highlight updates when components render" setting. Turn it on and use your app for thirty seconds. The components flashing that shouldn't be flashing are your list. That takes half a minute and beats any amount of guessing about where the time goes.