Rendering and performance
What actually triggers a React re-render, why most re-renders cost nothing, how to get list keys right, and when memo, useMemo and useCallback earn their keep — measured with the Profiler rather than guessed at.
12 cards · 6 quiz questions · 10 min read
An agent has built you a fieldwork dashboard: a filter bar, a chart, and a table of survey responses. It works, but it feels sticky when you type in the filter box, and somewhere in the diff are memo, useMemo and useCallback sprinkled across a dozen components. Are those there because something was measured, or because the agent had read that they make React faster? This topic is about telling those two situations apart, and about the one performance bug in this area that is a genuine correctness bug: list keys.
What a “re-render” actually is
Only two things trigger a render in React: the initial render, and a state update — calling a setter from useState, or dispatching with useReducer. That’s it. Mutating a plain variable does nothing. A prop changing does nothing on its own; the prop changed because the parent re-rendered, and that parent re-rendered because of its own state.
An update then goes through three steps. React renders: it calls your component functions to work out what the UI should look like, recursing into children. Then React commits: it applies the smallest set of DOM operations that makes the real DOM match that output.
The crucial part is that second step. React only touches DOM nodes whose output actually differs. If a component re-renders and produces the same input element as before, React leaves that element completely alone — the text you typed and the cursor position survive. So “this component re-rendered 40 times” is not, by itself, a problem. It means React called a function 40 times and mostly concluded there was nothing to change.
This is why “stop unnecessary re-renders” is the wrong opening move. Calling a function that returns some JSX is cheap. What is not cheap is doing real work inside that function, or giving React a genuinely large amount of DOM to commit.
Keys: the one that is a correctness bug
A key is how React matches an element in this render to an element in the previous render. Two rules, both from the React docs: keys must be unique among siblings, and keys must not change between renders.
Here is the version agents emit constantly:
// Before: index as key. Works until the list moves.
{tasks.map((task, i) => (
<FieldworkRow key={i} task={task} /> // i is position, not identity
))}
And the fix:
// After: a stable id from the data.
{tasks.map((task) => (
<FieldworkRow key={task.id} task={task} />
))}
Why it matters: suppose each row has a “chase this respondent” checkbox, whose ticked state lives inside FieldworkRow. A user ticks row 3. A new task arrives and is inserted at the top. With index keys, the keys are still 0, 1, 2, 3…, so React believes every element stayed put and only its props changed. It reuses row 3’s component state and DOM while handing it different data. The tick stays at position 3 and now points at the wrong respondent. Focus, scroll position inside a row, an open detail panel and half-typed notes all misbehave the same way.
There is no warning for this. Index keys are perfectly unique, so React has nothing to complain about. It is a silent data-integrity bug in a research tool.
Two related smells:
<Row key={Math.random()} row={row} /> // never matches; recreates everything
<Row key={crypto.randomUUID()} row={row} /> // same bug, fancier
A key generated during render never matches the previous one, so React throws away and rebuilds every row on every render — slow, and it destroys any user input inside those rows.
Index keys are defensible only when a list is truly static: never sorted, filtered, reordered, appended to or deleted from, and with no state inside the items. That is a promise about the future of the code, which is why a stable id is the better default.
Expensive renders versus expensive work
Two different problems get called “slow React”.
Expensive work in the wrong place is the common one. A slow computation sits in the component body, so it re-runs on every single render, including renders caused by an unrelated keystroke:
function Dashboard({ responses, filters }: Props) {
const [query, setQuery] = useState("");
// Runs on every keystroke, over 40,000 rows.
const crosstab = buildCrosstab(responses, filters);
return <>{/* ... */}</>;
}
useMemo is the right tool here, because the input genuinely rarely changes:
const crosstab = useMemo(
() => buildCrosstab(responses, filters),
[responses, filters], // not `query` — typing no longer recomputes
);
An expensive render is different: React has a lot of nodes to reconcile and commit, and the browser then has to lay them out. Forty thousand table rows is expensive no matter how clever your memoisation, because the cost is the DOM itself.
Using memo, useMemo and useCallback deliberately
memo(Component) tells React to skip re-rendering a component when its props are shallowly equal to last time, compared with Object.is. Understanding that comparison explains most disappointment with memo:
// Before: memo can never skip anything here.
const Table = memo(ResponseTable);
function Dashboard() {
return (
<Table
rows={rows}
options={{ sortBy: "date" }} // new object literal every render
onSelect={(id) => select(id)} // new function every render
/>
);
}
Both options and onSelect are fresh references on every parent render, so Object.is returns false and the memoised component renders anyway. You have paid for a comparison and bought nothing.
// After: stable references, so memo can actually skip.
const SORT_OPTIONS = { sortBy: "date" } as const; // hoisted; never changes
function Dashboard() {
const onSelect = useCallback((id: string) => select(id), [select]);
return <Table rows={rows} options={SORT_OPTIONS} onSelect={onSelect} />;
}
memo also cannot help when the component re-renders because of its own state, or because a context value it reads changed.
Two more things worth knowing. useMemo is documented as an optimisation with no semantic guarantee: React may discard the cache, and in Strict Mode it deliberately runs the calculation twice in development to catch impurity. So whatever is inside must be pure — never a fetch, never a write. And useMemo does nothing for the first render; there is nothing cached yet.
useCallback earns its place in exactly two situations: the function is passed to a memo-wrapped child, or it is a dependency of an effect you don’t want retriggered. On an onClick for a plain button, it is noise.
Virtualising long tables
When the DOM node count is the problem, the answer is to stop creating the nodes. Virtualisation renders only the rows in the viewport plus a small overscan, and sizes a spacer so the scrollbar behaves as if all rows were there. A library such as TanStack Virtual handles the measurement:
const rowVirtualizer = useVirtualizer({
count: responses.length, // 40,000
getScrollElement: () => parentRef.current,
estimateSize: () => 36, // px per row
overscan: 8,
});
// render only rowVirtualizer.getVirtualItems(), absolutely positioned
The trade-offs are real: browser find-in-page and “select all rows” only see what is rendered, and row heights that vary need measuring. Worth it at thousands of rows; pointless at two hundred.
Measure before you optimise
Open the React DevTools Profiler, record the slow interaction, and read the commits: which components rendered, how long each took, and what triggered them. That turns “the dashboard feels slow” into “ResponseTable commits for 780 ms on every keystroke”. You can also log durations directly with the Profiler component and its onRender callback, which receives id, phase, actualDuration, baseDuration, startTime and commitTime.
Two gotchas. Development builds are slower than production, so absolute numbers are inflated. And profiling is disabled in production builds by default, so a plain vite build bundle will not profile without a special profiling build.
What to check in your own code
- Grep for
key={i},key={index}andkey={Math.random— then ask whether that list can ever be sorted, filtered or reordered. - Check every
keycomes from data, not from position or from a value created during render. - Look for heavy calls (
sort,filterover large arrays,JSON.parse, date parsing, crosstabs) sitting bare in a component body. - For each
memo, check its props in the parent’s JSX for inline object, array or arrow-function literals. If any exist, thememois decoration. - For each
useCallback, ask who consumes the function. A plain DOM handler is not a reason. - Confirm no
useMemooruseCallbackbody performs a fetch, a write or anything else with a side effect. - If a table renders more than about a thousand rows, check whether it is virtualised.
- Before accepting any performance change from an agent, ask for the Profiler numbers before and after.
A dashboard fetches 200 survey responses and renders them as rows. The page feels fine. An agent has now wrapped every row component in memo and every handler in useCallback. What is the most likely effect?
Sources
- Render and Commit — React website The two triggers for a render, the three steps, and the rule that React only touches DOM nodes that actually differ.
- Rendering Lists — React website Keys must be unique among siblings and must not change; the documented pitfalls of array-index keys and keys generated during render.
- useMemo — React website Signature, the caveat that React may discard the cache, and the guidance to measure in a production build before memoising.
- memo — React website Shallow prop comparison with Object.is, and why memo does nothing when props are new objects each render.