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. Note: React has one built-in bail-out that costs nothing. When a component returns the same element object as last time — because that JSX arrived through children, created by a component that did not re-render — React skips the subtree entirely. Accepting children instead of hard-coding a subtree is the cheapest optimization in React, and it needs no Hook.

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>

Listing 2.50: Three children, one counter, and the shallow comparison 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. Warning: A custom comparison that ignores a prop silently freezes it: the predicate above never updates a row whose title changed under a stable id, and the bug looks like bad data rather than a rendering fault. Prefer stable prop identities to a clever predicate.

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>

Listing 2.51: A calculation that runs only when its input changes 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. Warning: useMemo is a performance hint, not a guarantee. React reserves the right to discard a cached value — it already does so for off-screen content — and call calculate again. Never put work in it whose execution matters: no subscriptions, no DOM writes, no state updates.

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>

Listing 2.52: The same memoized child, two kinds of handler 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. Tip: useCallback is not only for memo. A function in an Effect's dependency array re-runs that Effect whenever its identity changes, so a callback that subscribes to a socket is worth stabilizing even when no child is memoized.

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

Listing 2.53: useCallback expressed with useMemo The double arrow is the whole story: useMemo needs a function that returns the function you want to keep. useCallback exists because that shape is easy to mistype and hard to read. useMemo useCallback What is cached The return value The function object Is your function called? Yes, when a dependency changed Never by the Hook Typical use Derived data, filtered arrays, JSX Event handlers, Effect dependencies Returns on a cache hit Last computed value Last function, same address Table 2.12: The one difference between the two memoization Hooks The mistake this produces is writing useCallback(() => expensiveTotal(rows), [rows]) and expecting the total to be cached. It is not: you cached the wrapper, and expensiveTotal still runs in full whenever something calls it. Anything whose result you want to reuse belongs in useMemo — which can also do everything useCallback does, including keeping a whole object of handlers stable for a context value (Section 2.5.5). Tip: The react-hooks/exhaustive-deps lint rule can only check a dependency array written literally at the call site. useMemo(fn, deps) with deps held in a variable compiles and runs, but no tool will ever tell you the list is wrong.

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>;

    }

    Listing 2.54: Memoization that resets on every render memo returns a new component object every time Page runs, so React sees a different element type, throws away the whole subtree and remounts it — losing its DOM nodes, its state and its Effects along with the cache. Define components at module scope; the only correct place for memo is next to that definition. Tip: @welldone-software/why-did-you-render 10.0.1 (MIT, github.com/welldone-software/why-did-you-render (https://github.com/welldone-software/why-did-you-render)) patches React in development and logs why a component re-rendered, naming the prop whose identity changed. Install it with npm i -D @welldone-software/why-did-you-render, then set Component.whyDidYouRender = true.

    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>;

    }

    Listing 2.55: A component with no memoization at all Running it through babel-plugin-react-compiler 1.0.0 with target: '19' produces this:

    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;

    }

    Listing 2.56: The same component after the compiler _c(2) asks React for a two-slot cache that lives with this component instance, exactly like a Hook's state. Slot 0 holds the last name, slot 1 the last rendered <button>. When name is unchanged the whole element — handler included — is reused, so a parent re-render yields the identical element object and React skips the subtree: memo, useMemo and useCallback at expression granularity, with no dependency array to get wrong.The compiler's conditions are strict, and that is the point: it optimizes only code that follows the Rules of React (Section 2.2.8). Give it a component with a Hook inside an if and the output is byte-for-byte the input, with no _c import at all — it declines rather than risk changing behavior. Its limits follow from the same caution. It memoizes inside components and Hooks only, so a module-scope helper is never cached; it does not share a cache between components, so the same expensive call in two of them runs twice; and a changed context value still re-renders every reader, exactly as before.So what still earns a Hook? React's guidance keeps useMemo and useCallback as escape hatches for values whose identity you must control yourself — most often one used as an Effect dependency, where you need certainty the Effect will not re-fire. memo still marks a boundary you have deliberately profiled. And in code that already has memoization, leave it: removing it changes what the compiler emits, so retire it against measurements. Did you know? Meta shipped the compiler in the Quest Store before 1.0, and the release reports up to 12% faster initial loads and cross-page navigations, some interactions 2.5 times faster, and neutral memory use. Expo enables it by default from SDK 54 on.

    2.7.8 Adding the Compiler to a Build

    The compiler is one development dependency and one line of build configuration: npm install -D babel-plugin-react-compiler@latest eslint-plugin-react-hooks@latest Listing 2.57: Installing the compiler and the lint rules In a Vite 8 project, @vitejs/plugin-react 6.1.1 exposes the compiler as a Babel preset that runs through @rolldown/plugin-babel:

    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()] })],

    });

    Listing 2.58: vite.config.js with the React Compiler enabled On older Vite the plugin goes straight to Babel instead, with react({ babel: { plugins: ['babel-plugin-react-compiler'] } }). In a plain Babel setup, 'babel-plugin-react-compiler' must be first in the plugins array, ahead of the JSX transform. Next.js has its own switch, covered in Chapter 5.The lint rules moved house in the 1.0 release: they now live in eslint-plugin-react-hooks 7.1.1 rather than the separate eslint-plugin-react-compiler, and the recommended-latest preset turns on both the Hook rules and the compiler's diagnostics — set-state-in-render, set-state-in-effect and refs among them. Lint first: every rule it reports is a place the compiler would have to decline. Option What it controls Typical value compilationMode Which functions get compiled 'annotation' for opt-in target React version to emit for '17', '18' or '19' panicThreshold Code it cannot compile 'none': skip, do not fail gating Runtime flag for a staged rollout { source, importSpecifierName } logger Reporting of compiled files logEvent(filename, event) Table 2.13: The compiler options you are most likely to set A React 17 or 18 codebase works too: set target: '17' or '18' and add the react-compiler-runtime package, which supplies the c Hook that React 19 ships natively.For an existing application, turn it on gradually. compilationMode: 'annotation' compiles only functions whose body starts with the "use memo" directive, so you convert a screen at a time; Babel's overrides option does the same by directory. In the other direction, "use no memo" as a function's first statement excludes it — its compiled output is byte-for-byte its input. A module-level directive covers the file; a function-level one wins. Warning: Treat "use no memo" as a bug report, not a setting. The documented workflow is to add it with a TODO, open an issue, fix the rule violation, and delete the directive.