Why components re-run: the virtual DOM, memo and useCallback
November 20, 2022 · 8 min read
I put a console.log in three components, clicked one button, and all three logged. Meanwhile the browser inspector showed that only a single paragraph had changed. That gap, between components running and the page actually changing, is what this note is about.
React and the DOM are two different jobs
React on its own knows nothing about browsers. It tracks components, their props, their state and context, and works out what the UI should look like after a change. ReactDOM is the part that touches the real DOM. React decides, ReactDOM does. The same React core can feed other targets, which is why the two are separate packages.
The real DOM is the expensive bit. So React keeps a lightweight copy of the tree in memory, builds a new copy after every change, and compares the two. Only the difference is passed on.
If the only difference is a new paragraph after the heading, ReactDOM inserts that paragraph and leaves everything else alone. It does not rebuild the page.
Running a component is not touching the DOM
This is the line I kept blurring. When state changes, React calls the component function again. That is "re-evaluating". It is cheap, it only creates objects in memory. Whether the DOM changes depends on the comparison afterwards.
function App() {
const [show, setShow] = useState(false);
console.log("APP RUNNING");
const toggle = () => setShow((prev) => !prev);
return (
<div>
<h1>Hi there!</h1>
<Demo show={show} />
<Button onClick={toggle}>Toggle paragraph</Button>
</div>
);
}Click the button and the console shows App, Demo and Button. In the Elements tab only the p flashes. Button did not change at all and still ran.
Children run because the parent ran
I tried to test this by hard coding show={false} so Demo's props could never change. All three still logged. The reason is that JSX is just function calls. When App runs, it calls Demo and Button again as part of its return value. Nothing asks whether their props changed. The parent ran, so the whole branch below it runs, and so on down the tree.
For most apps this doesn't matter. React is fast at this. But in a big tree it adds up, so there is a way to opt out.
React.memo, and when it's worth it
Wrap a component in React.memo and React keeps its last props. Next time the parent runs, it compares the new props with the stored ones and skips the component if nothing differs.
export default React.memo(Demo);That works for Demo: first render logs all three, after a click only App and Button run. But memo isn't free. React has to store the old props and compare them every time, so you swap the cost of running the component for the cost of comparing. If a component's props change on nearly every parent run, memo only adds work.
So I don't put it on everything. The useful spots are high in the tree, where skipping one component skips a whole branch below it.
Why the memoised Button still ran
I wrapped Button in memo too, expecting it to go quiet. It kept logging. The props looked identical, same text, same onClick. But the comparison is basically ===, and that is where it breaks.
false === false // true, primitives compare by value
"hi" === "hi" // true
[1, 2, 3] === [1, 2, 3] // false, two different arrays
(() => {}) === (() => {}) // false, two different functionsFunctions are objects. toggle lives inside App, so every time App runs it creates a brand new function that does the same thing. To memo it is a different prop each time. Booleans and strings are fine because equal values are equal. Objects and functions are only equal if they are the exact same one in memory.
useCallback keeps the same function
useCallback tells React to store the function and hand back that same object on later runs. Now the comparison sees one function, and memo can do its job.
const toggle = useCallback(() => {
setShow((prev) => !prev);
}, []);The second argument is a dependency array, same idea as in useEffect. Anything the function uses from the component (state, props) belongs in it. Here the function only calls the state setter, which React guarantees never changes, so the array is empty. After this, Button logs once on the first render and never again.
The bug that taught me closures
I added a second button, "Allow toggling", with an allowToggle state, and made the toggle function check it first. I left the dependency array empty because that had worked before.
const toggle = useCallback(() => {
if (allowToggle) {
setShow((prev) => !prev);
}
}, []); // bugI clicked allow, then toggle, and nothing happened. This confused me for a while, because the state clearly said true.
What I had missed is that a function remembers the variables around it at the moment it is created. That is a closure. A small version:
function makeAdder(x) {
return (y) => x + y;
}
const add5 = makeAdder(5);
const add10 = makeAdder(10);
add5(2); // 7
add10(2); // 12makeAdder has finished by the time add5 is called, yet add5 still knows its x is 5. Each returned function carries its own x.
My handler did the same with allowToggle. It was created on the first run, when the value was false, and it kept that false. Without useCallback this never showed up, because every run made a new function with the fresh value. useCallback froze the old one. The fix is to tell React when the stored function has gone stale:
}, [allowToggle]);Now React keeps the function until allowToggle changes, then replaces it with a new one that closed over the new value. That is why the dependency array exists.
What stuck with me: a component running is cheap and the DOM changing is rare, and memo and useCallback only help if the values you pass are really the same ones.