MENU
Memoization and the React Compiler
React re-renders generously. When a component's state changes, React calls that component's function again and every component below it, and only then compares the result with what is on screen. The generosity is what lets you write components as plain functions of props and state instead of hand-wiring updates, and it is cheap â until "every component below it" means a thousand list rows, or one component whose render does real work.The traditional fix is memoization: memo skips a component whose props did not change, useMemo caches a value between renders, and useCallback caches a function so a child wrapped in memo keeps seeing the same prop. All three hold on to last render's result plus a list of dependencies, and all three are easy to get wrong â a list one entry short returns a stale value, one entry too long returns a fresh object every time and buys nothing.React Compiler 1.0, released on 7 October 2025, does that work at build time instead, which for most new applications makes all three unnecessary. They are not deprecated, and this section is explicit about where they still earn their place â but the order of the advice has flipped. Write the plain version, measure, and reach for a Hook only where the compiler cannot help.2.7.1 Why Components Re-render
A component function runs again for exactly three reasons: its own state changed, the component that rendered it ran again, or a context it reads was given a new value. Props changing is not on the list â a prop can only change because the parent re-rendered, and the parent re-rendering is what schedules the child. This is why the second reason surprises people: a child re-renders because of where it sits in the tree, not because anything it can see is different. A component that takes no props at all still runs every time its parent runs, and so does everything below it.A re-render is not a DOM update. React calls the function, gets a new tree of element objects, compares it with the previous tree, and sends only the differences to the DOM. A component that re-renders and returns identical output costs one function call and one diff, and nothing else. The cost becomes visible only when the function does real work â sorting ten thousand rows, formatting dates in a loop, building a chart's geometry â or when the subtree below it is large enough that the diff adds up.Measure before you act. The React Developer Tools Profiler (Section 2.1.8) records every commit and colors each component by how long its render took. The demos that follow do the same thing crudely, by incrementing a module-level counter inside each component and printing it on the page â a deliberate impurity no real component may copy, but the only honest way to put render counts in front of you.2.7.2 memo
memo wraps a component and returns one that remembers its last props and its last output. When its parent re-renders, React compares the new props with the old using Object.is on each key â a shallow comparison, one level deep â and if every prop matches it reuses the previous output and skips the component and everything below it.<div id="root"></div>
<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>
<script type="importmap">{ "imports": { "react": "https://esm.sh/react@19.3.0",
"react-dom/client": "https://esm.sh/react-dom@19.3.0/client" } }</script>
<script type="text/babel" data-type="module">
import React, { memo, useState } from 'react';
import { createRoot } from 'react-dom/client';
const n = { plain: 0, stable: 0, unstable: 0 };
function Plain() { n.plain++; return <li>plain, no props â render count {n.plain}</li>; }
const Stable = memo(function Stable({ label }) {
n.stable++;
return <li>memo, string prop "{label}" â render count {n.stable}</li>;
});
const Unstable = memo(function Unstable({ style }) {
n.unstable++;
return <li style={style}>memo, inline object prop â render count {n.unstable}</li>;
});
function App() {
const [count, setCount] = useState(0);
return <div style={{ font: '15px system-ui' }}>
<button onClick={() => setCount((c) => c + 1)}>Clicked {count} times</button>
<ul><Plain /><Stable label="steady" /><Unstable style={{ color: 'teal' }} /></ul>
</div>;
}
createRoot(document.getElementById('root')).render(<App />);
[100, 200, 300].forEach((t) => setTimeout(() => document.querySelector('button').click(), t));
</script>
Figure 2.28: Browser output of Listing 2.50The clicks are spaced 100 ms apart on purpose: three clicks fired in
the same task would be batched into one update and one render. As it is,
four renders of App. The plain child runs all four times although it
takes no props. Stable runs once â its only prop is a string that never
changes. Unstable runs four times, because { color: 'teal' } is a
new object on every render and Object.is on two different
objects is false: the wrap bought nothing and added a comparison.That failure is the reason useMemo and useCallback exist, and the
commonest way memo is wasted â an object, an array, or an inline arrow
passed as a prop defeats it every time. Three further limits matter.
memo guards props only, so a component holding its own state, or reading
a context whose value changed (Section 2.5.5), re-renders regardless; it
does nothing for elements arriving through children, which the parent
recreates; and every wrap retains the last props and output in
memory.An optional second argument replaces the shallow check with your own
predicate: memo(Row, (prev, next) => prev.item.id === next.item.id)
treats two rows as equal when their ids match. Note the polarity â
returning true means skip the render, the opposite of the
class-era shouldComponentUpdate.2.7.3 useMemo
useMemo(calculate, deps) calls calculate during the first render, stores the result, and on every later render compares each entry of deps with the previous one using Object.is. If all of them match it returns the stored value and never calls calculate at all.<div id="root"></div>
<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>
<script type="importmap">{ "imports": { "react": "https://esm.sh/react@19.3.0",
"react-dom/client": "https://esm.sh/react-dom@19.3.0/client" } }</script>
<script type="text/babel" data-type="module">
import React, { useMemo, useState } from 'react';
import { createRoot } from 'react-dom/client';
const stats = { runs: 0 };
function totalOf(rows) { // stands in for an expensive calculation
stats.runs++;
return rows.reduce((sum, r) => sum + r.price, 0);
}
function App() {
const [size, setSize] = useState(3);
const [dark, setDark] = useState(false);
const rows = useMemo(
() => Array.from({ length: size }, (_, i) => ({ id: i, price: i * 10 })), [size]);
const total = useMemo(() => totalOf(rows), [rows]);
return <div style={{ font: '15px system-ui' }}>
<button onClick={() => setSize((s) => s + 1)}>Add a row</button>{' '}
<button onClick={() => setDark((d) => !d)}>Toggle theme</button>
<ul><li>{rows.length} rows, total {total}, theme {dark ? 'dark' : 'light'}</li>
<li>totalOf ran {stats.runs} times</li></ul>
</div>;
}
createRoot(document.getElementById('root')).render(<App />);
const go = (i) => document.querySelectorAll('button')[i].click();
[100, 200].forEach((t) => setTimeout(() => go(0), t)); // two row changes
[300, 400, 500].forEach((t) => setTimeout(() => go(1), t)); // three theme changes
</script>
Figure 2.29: Browser output of Listing 2.51Five clicks give six renders, and totalOf runs three times: once at
mount and once for each new row. The three theme toggles change nothing
the calculation depends on, so it is skipped.Notice that rows is memoized too. Without that, each render would
build a fresh array, [rows] would never match, and the memo on total
would be worthless â memoizing a value pays off only when everything it
depends on is stable as well. This chain is what people get wrong by
hand and what the compiler in Section 2.7.7 gets right
automatically.The same Hook also caches JSX. An element is just an object, and
React skips a subtree whose element is reference-identical to the
previous one, so useMemo(() => <Child a={a} />, [a]) gives you
the effect of memo without wrapping Child.2.7.4 useCallback
A function declared inside a component is a new object on every render, so passing one to a memoized child defeats the shallow comparison exactly the way an inline object does. useCallback(fn, deps) returns the same function instance for as long as every dependency matches. It never calls fn and does not make it faster; all it changes is the address the child receives.<div id="root"></div>
<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>
<script type="importmap">{ "imports": { "react": "https://esm.sh/react@19.3.0",
"react-dom/client": "https://esm.sh/react-dom@19.3.0/client" } }</script>
<script type="text/babel" data-type="module">
import React, { memo, useCallback, useState } from 'react';
import { createRoot } from 'react-dom/client';
const n = { fresh: 0, cached: 0 };
const Child = memo(function Child({ id, onPing }) {
n[id]++;
return <li onClick={onPing}>handler from {id} â render count {n[id]}</li>;
});
function App() {
const [count, setCount] = useState(0);
const fresh = () => {}; // new address every render
const cached = useCallback(() => {}, []); // same address every render
return <div style={{ font: '15px system-ui' }}>
<button onClick={() => setCount((c) => c + 1)}>Clicked {count} times</button>
<ul><Child id="fresh" onPing={fresh} /><Child id="cached" onPing={cached} /></ul>
</div>;
}
createRoot(document.getElementById('root')).render(<App />);
[100, 200, 300].forEach((t) => setTimeout(() => document.querySelector('button').click(), t));
</script>
Figure 2.30: Browser output of Listing 2.52Same component, same work, same number of parent renders: the child
holding the fresh arrow renders four times, the one holding the cached
arrow renders once. useCallback alone would have changed nothing â it
pays off only because Child is wrapped in memo. Wrapping handlers in a
component whose children are not memoized is pure overhead.The dependency array is where this goes wrong. useCallback(() =>
save(text), []) keeps one function forever, holding the text from the
first render â a stale closure that saves an empty string no matter what
the user typed. Listing every reactive value it reads ([text]) fixes the
staleness and destroys the stability, since the identity now changes on
every keystroke. When you need both, the answer is useEffectEvent
(Section 2.4.7), which replaces the hand-rolled "write the value into a
ref, read it back inside a useCallback" trick.2.7.5 useMemo Versus useCallback
The two Hooks have the same signature and the same dependency rules, and they differ in one thing: what gets cached. useMemo runs your function and caches what it returned. useCallback caches the function itself and never runs it. That makes useCallback(fn, deps) nothing more than shorthand:const handleSave = useCallback(() => save(draft), [draft]);
const handleSave = useMemo(() => () => save(draft), [draft]); // exactly the same thing
2.7.6 When Memoization Backfires
Memoization is never free. Each useMemo or useCallback allocates a dependency array on every render and compares it entry by entry; each memo retains the last props and output while the component is mounted. Against a calculation that takes a microsecond, the bookkeeping costs more than the work. Four patterns lose outright:A memoized component with an unstable prop. The comparison runs, fails, and the component renders anyway. The Unstable child in Section 2.7.2 pays for memo four times and benefits zero times.
Memoizing what is already stable. State setters, dispatch, refs and imported functions never change identity. Wrapping them changes nothing and adds a line a reader must verify.
Dependency arrays tuned by hand. Removing an entry to "make it stable" produces a stale closure, invisible until a user reports wrong data; adding one that changes every render turns the Hook into pure overhead.
A component defined inside another component. This one destroys memoization completely, and it is common.
function Page({ rows }) {
const Row = memo(({ row }) => <li>{row.title}</li>); // a NEW component type each render
return <ul>{rows.map((row) => <Row key={row.id} row={row} />)}</ul>;
}
2.7.7 The React Compiler
React Compiler is a build-time tool â a Babel plugin, MIT licensed, developed in the facebook/react repository â that inserts the memoization you would otherwise write by hand. Version 1.0 shipped on 7 October 2025 after a year in beta, and babel-plugin-react-compiler 1.0.0 is still the current release. It rewrites each component so that an expression is recomputed only when something it reads has changed. Your source does not change. This is an ordinary component:function Hello({ name }) {
const onClick = () => alert(name);
return <button onClick={onClick}>Hi</button>;
}
import { c as _c } from "react/compiler-runtime";
function Hello(t0) {
const $ = _c(2);
const {
name
} = t0;
let t1;
if ($[0] !== name) {
const onClick = () => alert(name);
t1 = <button onClick={onClick}>Hi</button>;
$[0] = name;
$[1] = t1;
} else {
t1 = $[1];
}
return t1;
}
2.7.8 Adding the Compiler to a Build
The compiler is one development dependency and one line of build configuration:import { defineConfig } from 'vite';
import react, { reactCompilerPreset } from '@vitejs/plugin-react';
import babel from '@rolldown/plugin-babel';
export default defineConfig({
plugins: [react(), babel({ presets: [reactCompilerPreset()] })],
});