Understanding & Controlling Re-renders
Know exactly why a component re-renders before you reach for memo — most 'slow React' is misdiagnosed.
Before optimizing anything, internalize the one rule: a component re-renders when its state changes, when its parent re-renders, or when the context it reads changes. That is it. A re-render is not inherently bad — React is fast, and rendering a component just runs its function and diffs the result. Problems appear only when expensive components re-render needlessly.
The reality that surprises people
When a parent re-renders, all its children re-render by default — even if their props did not change. React does not compare props unless you ask it to. That is why memo exists: it tells React to skip a child whose props are shallow-equal to last time.
Your toolkit, in order of preference
- Move state down. Colocate state so fewer components sit under the one that changes. This eliminates re-renders instead of skipping them.
- Pass children through. Content received as
childrendoes not re-render when the receiving component's own state changes — it was built by the parent above. - memo the expensive child — but only if its props are stable. A new object/array/function prop each render makes
memouseless. - Stabilize props with
useMemo/useCallbacksomemocan actually hit.
// memo only helps if `items` keeps the same identity between renders.
const List = memo(function List({ items }) {
return <ul>{items.map(i => <li key={i.id}>{i.name}</li>)}</ul>;
});Measure, do not guess
Use the React DevTools Profiler: record an interaction, see which components rendered and why, and how long each took. Optimize the ones that are both expensive and frequent. And note that the React Compiler now auto-memoizes much of this — hand-tuning is increasingly a targeted fix, not a default chore.
Example
import { useState, memo } from 'react';
// Expensive child, memoized. It renders 5000 rows.
const BigList = memo(function BigList({ rows }) {
return (
<ul>
{rows.map((r) => <li key={r.id}>{r.label}</li>)}
</ul>
);
});
function Screen({ rows }) {
const [count, setCount] = useState(0);
return (
<div>
{/* Clicking updates `count` and re-renders Screen,
but BigList is skipped because `rows` is unchanged. */}
<button onClick={() => setCount((c) => c + 1)}>Clicked {count}</button>
<BigList rows={rows} />
</div>
);
}
// Anti-pattern to avoid: passing a fresh array each render, e.g.
// <BigList rows={rows.filter(Boolean)} /> -> new identity -> memo never hits.When to use it
- Profiling a slow admin dashboard reveals one expensive <DataGrid> re-renders on every parent keystroke because its props object is recreated inline; wrapping it with React.memo stops the unnecessary renders.
- A chat message list re-renders all 500 messages whenever the typing indicator updates; moving the indicator into a separate component scopes the re-render to that subtree only.
- A sidebar component re-renders every time the authenticated user object reference changes on login refresh; stabilizing the context value with useMemo prevents cascading child re-renders.
More examples
React.memo stops prop-equal re-renders
React.memo does a shallow prop comparison and skips re-rendering DataRow when the parent Table re-renders but the row's data has not changed.
const DataRow = React.memo(function DataRow({ id, name, value }) {
return (
<tr>
<td>{id}</td>
<td>{name}</td>
<td>{value}</td>
</tr>
);
});
// DataRow only re-renders when id, name, or value actually change
function Table({ rows }) {
return (
<tbody>
{rows.map((r) => <DataRow key={r.id} {...r} />)}
</tbody>
);
}Stable callback with useCallback
useCallback keeps handleSelect referentially stable across renders so React.memo on ProductCard can skip re-rendering when only unrelated parent state changes.
function ProductList({ categoryId }) {
const [selected, setSelected] = React.useState(null);
// Without useCallback, a new function reference is created every render,
// defeating React.memo on ProductCard
const handleSelect = React.useCallback(
(id) => setSelected(id),
[] // no deps — setSelected is stable
);
return <ProductCard onSelect={handleSelect} categoryId={categoryId} />;
}Profile before optimizing
The Profiler component measures actual render time and flags commits that exceed a 16 ms frame budget, pointing you to where optimization is actually needed.
import { Profiler } from 'react';
function onRender(id, phase, actualDuration) {
if (actualDuration > 16) {
console.warn(`Slow render in <${id}> (${phase}): ${actualDuration.toFixed(1)}ms`);
}
}
function App() {
return (
<Profiler id="Dashboard" onRender={onRender}>
<Dashboard />
</Profiler>
);
}
Discussion