Performance all 3 written
When React re-renders, counted rather than guessed, what memo, useMemo and useCallback buy, and transitions.
22 When React re-renders, counted; keys and identity
React re-renders a component when its state changes, when its parent re-renders, or when a context it reads changes. That is all. Because a render is only a function call, doing it twice is not an error, but doing it for nothing is worth knowing about. This lesson counts renders so that you can see which ones happen, and then looks at the other half of the story: what React keeps and what it throws away between renders.
A parent renders, so its children render
When a component's state changes, React calls it again, and every component it returns is called
too, whether or not the props changed. The panel counts. Plain is written inside the
parent's JSX and gets the same props every time; Passed is created outside and arrives
as children. A component that arrives as children is the same element
object on every render of the parent, and React skips an element it has already seen with the same
identity.
counts.jsx
import { useState, act } from 'react';
import { createRoot } from 'react-dom/client';
const renders = { parent: 0, first: 0, second: 0, passed: 0 };
function Plain({ name }) {
renders[name] += 1;
return <i>{name}</i>;
}
function Passed() {
renders.passed += 1;
return <i>passed</i>;
}
function Parent({ children }) {
const [n, setN] = useState(0);
renders.parent += 1;
return (
<div>
<button onClick={() => setN(n + 1)}>clicked {n}</button>
<Plain name="first" />
<Plain name="second" />
{children}
</div>
);
}
const root = createRoot(stage);
await act(async () => root.render(<Parent><Passed /></Parent>));
for (let i = 0; i < 3; i++) await act(async () => stage.querySelector('button').click());
console.log(renders);
check('the parent: once to mount, three times for the clicks', renders.parent, 4);
check('a child with the same props still rendered every time', renders.first, 4);
check('and so did its sibling', renders.second, 4);
check('the element passed as children was skipped', renders.passed, 1);
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node counts.jsx
{ parent: 4, first: 4, second: 4, passed: 1 }
ok the parent: once to mount, three times for the clicks
ok a child with the same props still rendered every time
ok and so did its sibling
ok the element passed as children was skipped
So "the props did not change" is not a reason React skips a component. What lets it skip is the
same element, which is what lesson 23's memo adds by comparing props. Until then, the
cheapest tool is the one in the panel: build the slow part once, higher up, and pass it down.
Keys: which item is which
Between renders React matches the old list of children to the new one, and the key is how it matches them. The index of an item is a poor key if the list can change at the front: position 0 holds a different item afterwards, but React matches by key, so it reuses the old position-0 element, along with anything the DOM was holding there. The panel types into the first input, adds a name at the head, and reads each input.
keys.jsx
import { useState, act } from 'react';
import { createRoot } from 'react-dom/client';
let addAtHead;
function Names({ byId }) {
const [names, setNames] = useState([
{ id: 'a', name: 'Ada' },
{ id: 'b', name: 'Bob' },
]);
addAtHead = () => setNames([{ id: 'c', name: 'Cy' }, ...names]);
return (
<ul>
{names.map((item, index) => (
<li key={byId ? item.id : index}>
{item.name} <input aria-label={item.name} />
</li>
))}
</ul>
);
}
async function run(byId) {
const root = createRoot(stage);
await act(async () => root.render(<Names byId={byId} />));
stage.querySelector('input').value = 'typed';
await act(async () => addAtHead());
const rows = Array.from(stage.querySelectorAll('li')).map(
(li) => li.textContent.trim() + ':' + li.querySelector('input').value,
);
await act(async () => root.unmount());
return rows;
}
const byIndex = await run(false);
const byKey = await run(true);
console.log(byIndex, byKey);
check('index keys: the text stayed in the first row, beside Cy', byIndex, ['Cy:typed', 'Ada:', 'Bob:']);
check('id keys: the text moved down with Ada', byKey, ['Cy:', 'Ada:typed', 'Bob:']);
What node printed (React 19.2.7, development build), when the build ran it
node keys.jsx
[ 'Cy:typed', 'Ada:', 'Bob:' ] [ 'Cy:', 'Ada:typed', 'Bob:' ] ok index keys: the text stayed in the first row, beside Cy ok id keys: the text moved down with Ada
With index keys the new first row inherited Ada's input and its text; with ids, React saw that
a had moved, moved the element and left the new row's input empty. Use the item's own
id whenever the list can be reordered, filtered or added to at the front. Index keys are fine for a
list that never changes.
A new key is a new component
The key does a second job. If the key of a component changes, React does not update it, it throws it away and makes a new one, and the new one starts with fresh state. That is the clean way to reset a component, such as a form when you switch to another person: key it by that person.
reset.jsx
import { useState, act } from 'react';
import { createRoot } from 'react-dom/client';
function Counter() {
const [n, setN] = useState(0);
return <button onClick={() => setN(n + 1)}>{n}</button>;
}
function Switcher() {
const [who, setWho] = useState('ada');
return (
<div>
<a onClick={() => setWho('bob')}>switch</a>
<Counter key={who} />
</div>
);
}
const root = createRoot(stage);
await act(async () => root.render(<Switcher />));
const button = () => stage.querySelector('button');
await act(async () => button().click());
await act(async () => button().click());
const before = button().textContent;
await act(async () => stage.querySelector('a').click());
const after = button().textContent;
console.log(before, after);
check('the counter had reached 2', before, '2');
check('a new key started it from 0', after, '0');
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node reset.jsx
2 0 ok the counter had reached 2 ok a new key started it from 0
- A component renders when its state changes, its parent renders, or a context it reads changes.
- A child with unchanged props still renders when its parent does.
- An element passed as
childrenfrom above is the same object each time and is skipped. - Keys match old items to new ones. Use ids, not indexes, for lists that change.
- A changed key means a new component with fresh state.
- Count renders with a counter in the body; do not guess.
23 memo, useMemo, useCallback, measured
Lesson 22 ended with a plain fact: a parent renders, so its children render. React has three tools for the cases where that matters. They are not free (they cost memory and comparison work on every render), so the habit is to measure first and use them where the numbers show a need. This lesson gives each tool a count that proves it worked, and a measured comparison that shows when it pays.
memo: skip when the props are equal
memo(Component) wraps a component so that React compares its new props with its old
ones, one by one, with Object.is. If every prop is the same, React reuses the last
result and does not call the function. The panel renders two copies of the same child, one wrapped
and one not, and re-renders the parent three times.
memo.jsx
import { memo, useState, act } from 'react';
import { createRoot } from 'react-dom/client';
const renders = { plain: 0, memoised: 0 };
function Plain({ label }) {
renders.plain += 1;
return <i>{label}</i>;
}
const Memoised = memo(function Memoised({ label }) {
renders.memoised += 1;
return <i>{label}</i>;
});
function Parent() {
const [n, setN] = useState(0);
return (
<div>
<button onClick={() => setN(n + 1)}>clicked {n}</button>
<Plain label="plain" />
<Memoised label="memo" />
</div>
);
}
const root = createRoot(stage);
await act(async () => root.render(<Parent />));
for (let i = 0; i < 3; i++) await act(async () => stage.querySelector('button').click());
console.log(renders);
check('without memo: once to mount and once per click', renders.plain, 4);
check('with memo: only the mount', renders.memoised, 1);
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node memo.jsx
{ plain: 4, memoised: 1 }
ok without memo: once to mount and once per click
ok with memo: only the mount
An object or function written in the parent defeats it
Objects and functions are compared by identity. { size: 1 } written in the parent's
JSX is a new object on every render, and so is an arrow function, so the comparison says "changed"
every time and memo never gets to skip. useMemo keeps the same object, and
useCallback keeps the same function, as long as their dependencies are the same.
identity.jsx
import { memo, useState, useMemo, useCallback, act } from 'react';
import { createRoot } from 'react-dom/client';
const renders = { inline: 0, kept: 0 };
const Inline = memo(function Inline({ style, onPick }) {
renders.inline += 1;
return <i>inline</i>;
});
const Kept = memo(function Kept({ style, onPick }) {
renders.kept += 1;
return <i>kept</i>;
});
function Parent() {
const [n, setN] = useState(0);
const style = useMemo(() => ({ size: 1 }), []);
const onPick = useCallback(() => {}, []);
return (
<div>
<button onClick={() => setN(n + 1)}>clicked {n}</button>
<Inline style={{ size: 1 }} onPick={() => {}} />
<Kept style={style} onPick={onPick} />
</div>
);
}
const root = createRoot(stage);
await act(async () => root.render(<Parent />));
for (let i = 0; i < 3; i++) await act(async () => stage.querySelector('button').click());
console.log(renders);
check('inline object and function: memo could not skip', renders.inline, 4);
check('useMemo and useCallback: memo skipped every time', renders.kept, 1);
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node identity.jsx
{ inline: 4, kept: 1 }
ok inline object and function: memo could not skip
ok useMemo and useCallback: memo skipped every time
useMemo: keep a calculation
useMemo(calculate, deps) runs the function and remembers the result until a
dependency changes. It is for a calculation that is slow, or for an object that must keep its
identity as above. The panel counts how many times the calculation ran across three unrelated
re-renders, with and without useMemo, and then changes the dependency.
use-memo.jsx
import { useMemo, useState, act } from 'react';
import { createRoot } from 'react-dom/client';
let direct = 0;
let cached = 0;
let setWords;
function Parent() {
const [n, setN] = useState(0);
const [words, setWordsState] = useState(['b', 'a']);
setWords = setWordsState;
direct += 1;
const sortedDirect = [...words].sort();
const sortedCached = useMemo(() => {
cached += 1;
return [...words].sort();
}, [words]);
return (
<div>
<button onClick={() => setN(n + 1)}>clicked {n}</button>
<p>{sortedDirect.join('')} {sortedCached.join('')}</p>
</div>
);
}
const root = createRoot(stage);
await act(async () => root.render(<Parent />));
for (let i = 0; i < 3; i++) await act(async () => stage.querySelector('button').click());
const afterClicks = { renders: direct, cached };
await act(async () => setWords(['d', 'c']));
console.log(afterClicks, cached, stage.querySelector('p').textContent);
check('the parent rendered four times', afterClicks.renders, 4);
check('the calculation ran once, though it was rendered four times', afterClicks.cached, 1);
check('a changed dependency ran it again', cached, 2);
check('and both versions agree', stage.querySelector('p').textContent, 'cd cd');
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node use-memo.jsx
{ renders: 4, cached: 1 } 2 cd cd
ok the parent rendered four times
ok the calculation ran once, though it was rendered four times
ok a changed dependency ran it again
ok and both versions agree
Is it worth it? Measure
A render that takes microseconds does not need any of this. A render that takes milliseconds,
repeated on every keystroke, does. The child below does a deliberately heavy loop. The panel updates
the parent twenty times, once with the child plain and once with it wrapped in memo,
and compares the two totals. The page prints counts and whether the ratio beat a generous bound;
the real figure depends on the machine and is not printed, so the page is the same every time you
build it.
measure.jsx
import { memo, useState, act } from 'react';
import { createRoot } from 'react-dom/client';
const renders = { plain: 0, memoised: 0 };
let sink = 0;
function heavy() {
let total = 0;
for (let i = 0; i < 8000000; i++) total += i % 7;
sink += total;
}
function PlainChild() {
renders.plain += 1;
heavy();
return <i>child</i>;
}
const MemoChild = memo(function MemoChild() {
renders.memoised += 1;
heavy();
return <i>child</i>;
});
function Parent({ memoised }) {
const [n, setN] = useState(0);
return (
<div>
<button onClick={() => setN(n + 1)}>clicked {n}</button>
{memoised ? <MemoChild /> : <PlainChild />}
</div>
);
}
async function timeTwentyUpdates(memoised) {
const root = createRoot(stage);
await act(async () => root.render(<Parent memoised={memoised} />));
const start = performance.now();
for (let i = 0; i < 20; i++) await act(async () => stage.querySelector('button').click());
const elapsed = performance.now() - start;
await act(async () => root.unmount());
return elapsed;
}
const plainMs = await timeTwentyUpdates(false);
const memoMs = await timeTwentyUpdates(true);
const ratio = plainMs / Math.max(memoMs, 0.001);
console.log(renders);
console.log('memo was at least 3x faster:', ratio >= 3);
check('the plain child rendered for the mount and all 20 updates', renders.plain, 21);
check('the memo child rendered only for the mount', renders.memoised, 1);
check('memo was at least 3x faster', ratio >= 3, true);
check('the loop really ran', sink > 0, true);
What node printed (React 19.2.7, development build), when the build ran it
node measure.jsx
{ plain: 21, memoised: 1 }
memo was at least 3x faster: true
ok the plain child rendered for the mount and all 20 updates
ok the memo child rendered only for the mount
ok memo was at least 3x faster
ok the loop really ran
A note on the React Compiler. React has a build-time compiler that adds this
kind of memoisation for you, so you write fewer useMemo and useCallback
calls by hand. It is a separate tool that you add to a project's build. Nothing here runs it, and
the counts above are what React does without it.
memoskips a component whose props are allObject.is-equal to last time.- An object or function written in the parent is new every render, which defeats
memo. useMemokeeps a value;useCallbackkeeps a function; both until a dependency changes.- Prove a change helped with a count or a ratio; do not trust a feeling.
- Memoise where a render is expensive and often repeated, not everywhere.
24 Transitions and useDeferredValue
Click a tab, and the new tab needs a moment to get ready. Do you blank the
screen and show a spinner, or leave the old tab up until the new one can replace it? React 18 added
a way to say which you mean, and React 19 extended it to work with async functions. This lesson shows
both outcomes, then the value-side cousin, useDeferredValue.
isPending, around an async function
useTransition() returns [isPending, startTransition]. Anything you
change inside startTransition is a transition: non-urgent, and interruptible by an
urgent update. In React 19 the function can be async, and isPending stays true until
it finishes. The panel holds a promise open so it can look at the screen in the middle, then
releases it. An effect logs every change of isPending.
pending.jsx
import { useState, useTransition, useEffect, act } from 'react';
import { createRoot } from 'react-dom/client';
let release;
const held = new Promise((resolve) => {
release = resolve;
});
const seen = [];
function Tabs() {
const [tab, setTab] = useState('home');
const [isPending, startTransition] = useTransition();
useEffect(() => {
seen.push(isPending);
}, [isPending]);
return (
<button
onClick={() =>
startTransition(async () => {
await held;
setTab('posts');
})
}
>
{tab}
</button>
);
}
const root = createRoot(stage);
await act(async () => root.render(<Tabs />));
await act(async () => stage.querySelector('button').click());
const during = stage.textContent;
await act(async () => release());
const after = stage.textContent;
console.log(seen, during, after);
check('isPending went false, true, false', seen, [false, true, false]);
check('the old tab stayed on screen while pending', during, 'home');
check('the new one replaced it when the action finished', after, 'posts');
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node pending.jsx
[ false, true, false ] home posts ok isPending went false, true, false ok the old tab stayed on screen while pending ok the new one replaced it when the action finished
Keep the old screen instead of a fallback
The usual reason to use a transition is a change that needs data. Below, the content of a tab is
read with use from a promise that is not ready (lesson 26 explains
use), and Suspense shows a fallback while the content is missing (lesson
25). Switch tabs with an ordinary update and the fallback replaces the screen (React keeps the
old content in the page but hides it, so the panel reads only what is visible). Switch with a
transition and React keeps the old content up, marks isPending, and swaps when the new
content arrives. The panel records the visible text at each step.
fallback.jsx
import { Suspense, use, useState, useTransition, useEffect, act } from 'react';
import { createRoot } from 'react-dom/client';
function makeLoader() {
const cache = new Map();
const gates = {};
const load = (tab) => {
if (!cache.has(tab)) {
cache.set(tab, new Promise((resolve) => {
gates[tab] = () => resolve(tab + ' content');
}));
}
return cache.get(tab);
};
return { load, gates };
}
async function run(withTransition) {
const { load, gates } = makeLoader();
const screens = [];
const pendingLog = [];
function Content({ tab }) {
return <p>{use(load(tab))}</p>;
}
function App() {
const [tab, setTab] = useState('home');
const [isPending, startTransition] = useTransition();
useEffect(() => {
pendingLog.push(isPending);
}, [isPending]);
const go = () => (withTransition ? startTransition(() => setTab('posts')) : setTab('posts'));
return (
<div>
<button onClick={go}>go</button>
<Suspense fallback={<p>loading</p>}>
<Content tab={tab} />
</Suspense>
</div>
);
}
const text = () =>
Array.from(stage.querySelectorAll('p'))
.filter((p) => p.style.display !== 'none')
.map((p) => p.textContent)
.join('');
const root = createRoot(stage);
await act(async () => root.render(<App />));
await act(async () => gates.home());
screens.push(text());
await act(async () => stage.querySelector('button').click());
screens.push(text());
await act(async () => gates.posts());
screens.push(text());
await act(async () => root.unmount());
return { screens, pendingLog };
}
const plain = await run(false);
const transition = await run(true);
console.log(plain, transition);
check('an ordinary update: the fallback replaced the screen', plain.screens, ['home content', 'loading', 'posts content']);
check('a transition: the old content stayed until the new was ready', transition.screens, ['home content', 'home content', 'posts content']);
check('isPending was only true in the transition', [plain.pendingLog, transition.pendingLog], [[false], [false, true, false]]);
What node printed (React 19.2.7, development build), when the build ran it
node fallback.jsx
{
screens: [ 'home content', 'loading', 'posts content' ],
pendingLog: [ false ]
} {
screens: [ 'home content', 'home content', 'posts content' ],
pendingLog: [ false, true, false ]
}
ok an ordinary update: the fallback replaced the screen
ok a transition: the old content stayed until the new was ready
ok isPending was only true in the transition
useDeferredValue: the old value first
useDeferredValue(value) is for when the value comes from somewhere you do not
control, such as a prop. After a change React first renders with the old deferred value, so
the urgent part of the screen is up to date straight away, and then renders again with the new one.
The panel logs the pair "urgent value / deferred value" on every render.
deferred.jsx
import { useDeferredValue, act } from 'react';
import { createRoot } from 'react-dom/client';
const log = [];
function Search({ query }) {
const deferred = useDeferredValue(query);
log.push(query + '/' + deferred);
return <p>{query}</p>;
}
const root = createRoot(stage);
await act(async () => root.render(<Search query="a" />));
await act(async () => root.render(<Search query="ab" />));
console.log(log);
check('first the old deferred value, then the new', log, ['a/a', 'ab/a', 'ab/ab']);
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node deferred.jsx
[ 'a/a', 'ab/a', 'ab/ab' ] ok first the old deferred value, then the new
The point is that the second render can be interrupted. If more typing arrives before it is done, React abandons it and starts again from the newest text, so a slow list never blocks the input. Lesson 22's counts and lesson 23's measurements tell you whether you have a slow list to begin with. See also the event loop, for why a long task blocks everything else on a single thread.
startTransitionmarks an update as non-urgent;isPendingtells you it is in flight.- In React 19 the function may be async;
isPendingstays true until it ends. - A transition keeps the old screen where an ordinary update would show the fallback.
useDeferredValuereturns the old value first and the new one in a later render.