Optimising React Components
14. April, 2023 • 6 min read • Teach
The hook you probably do not need yet
useCallback is the hook people reach for first when a React app feels slow, and it is the one that does nothing at all in most of the places it gets used. It is worth understanding exactly what it buys you before sprinkling it over a component tree.
React re-renders a component when its state or props change, and re-renders every child underneath it while it is at it. That is the default, and it is usually fine. When it stops being fine, useCallback is one of the tools available, alongside useMemo and React.memo, all of which arrived properly into everyday use around the same time as the React 18 concurrent renderer.
What it actually does
useCallback returns the same function reference across renders, as long as the values in its dependency array have not changed:
import React, { useState, useCallback } from 'react';
const App = () => {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
setCount(count + 1);
}, [count]);
return (
<div>
<p>Count: {count}</p>
<button onClick={handleClick}>Increment</button>
</div>
);
};Without the hook, handleClick would be a brand new function object on every render. With it, React hands back the one it stored last time until count changes.
Now the awkward part. In the example above that is worth precisely nothing. The only consumer of handleClick is a DOM <button>, and the DOM does not care whether the handler is the same object as last time. React will attach it either way. The component re-renders exactly as often as it did before, the counter behaves identically, and you have added a hook, an array and a dependency to maintain.
I keep this example in because it is the shape you will find in most tutorials, and it is worth knowing that the shape is a no-op.
Making it do something
Function identity only matters when something is watching it. In practice that is one of two things: a child component wrapped in React.memo, or another hook’s dependency array.
Here is the React.memo case:
import React, { memo, useState, useCallback } from 'react';
const Child = memo(({ name, onClick }) => {
console.log('rendering', name);
return <button onClick={() => onClick(name)}>{name}</button>;
});
const Parent = () => {
const [selectedName, setSelectedName] = useState('');
const handleChildClick = useCallback(name => {
setSelectedName(name);
}, []);
return (
<div>
<p>Selected name: {selectedName}</p>
<Child name="Thor" onClick={handleChildClick} />
<Child name="Loki" onClick={handleChildClick} />
<Child name="Odin" onClick={handleChildClick} />
</div>
);
};memo tells React to skip re-rendering Child when its props are shallowly equal to last time. Click one of the buttons, selectedName changes, Parent re-renders. Because handleChildClick came out of useCallback with an empty dependency array, all three children get the same onClick and the same name they had before, so none of them re-render. The console stays quiet.
Take memo off Child and all three log again on every click, useCallback or not. Take useCallback off and leave memo on, and you get the same result: a fresh function each render means the props are never shallowly equal, so memo never gets to skip anything. Both halves are required. This is the single most common way the optimisation gets applied and quietly does nothing.
The second case is a dependency array:
const load = useCallback(() => {
fetch(`/api/articles?page=${page}`).then(/* ... */);
}, [page]);
useEffect(() => {
load();
}, [load]);Here the effect depends on load. If load were recreated on every render, the effect would run on every render, which is a fetch loop rather than an optimisation. useCallback is what makes that dependency stable.
The dependency array is the interesting bit
Look again at the first example. [count] means a new function on every single increment, which is every render that matters. The memoisation is technically working and practically pointless.
The functional update form fixes it:
const handleClick = useCallback(() => {
setCount(current => current + 1);
}, []);setCount from useState is guaranteed stable by React, so it does not need to be in the array, and passing a function to it means we no longer need to read count during render. Empty dependencies, one function for the lifetime of the component. Whenever a useCallback has a dependency that changes as often as the component re-renders, this is the first thing to try.
One more detail that surprises people: the inline arrow function is still created on every render. It has to be, because it is the argument you are passing to useCallback. The hook then throws it away and returns the cached one. You are not saving the allocation, you are stabilising the identity. If someone tells you useCallback avoids creating functions, they have it backwards.
When to skip it
The honest default is to skip it and add it when you have a measured reason. More specifically, leave it out when:
Nothing is observing the identity
If the function is only used inside the component, or handed to a plain DOM element, or passed to a child that is not memoised, useCallback changes nothing about how often anything renders:
const Button = () => {
const handleClick = () => {
console.log('Button clicked!');
};
return <button onClick={handleClick}>Click me</button>;
};This is fine as it is. Wrapping handleClick would add a hook call, a closure held in memory for the life of the component, and no behaviour change.
The child is cheap to render
memo is not free. It runs a shallow comparison of every prop on every render of the parent. For a component that renders one button and some text, the comparison can cost more than the render it is avoiding. Reach for memo and useCallback around genuinely expensive subtrees: long lists, charts, anything doing real work in render.
You would have to memoise half the tree to make it work
Shallow equality means an inline object or array prop breaks memo just as effectively as an unstable function does:
<Child onClick={handleChildClick} style={{ margin: 8 }} />That style object is new every render, so memo never skips, and the useCallback next to it is wasted. If making one child memoisable requires wrapping four other values in useMemo, the cure is worse than the disease. That is usually a signal to restructure, moving state closer to where it is used, so fewer components re-render in the first place.
What I actually do
I write components without any of this, and I add useCallback in two situations: when a function is a dependency of useEffect or useMemo, or when the React DevTools profiler shows me a subtree re-rendering that has no business re-rendering. The first is a correctness concern and the second is a measured one. Everything in between is guesswork that makes the code harder to read.
Which is my one real irritation with this hook. It is not that it is slow, it is that it looks like diligence. A file full of useCallback reads as careful, performance-minded code, and most of the time it is decoration with a dependency array attached.
‘Till next time!