Effects and refs all 5 written
Synchronising with the world outside React, the dependency array, StrictMode's double run, when not to use an effect, and refs.
13 useEffect: synchronising with the outside
A component's job is to describe the screen from its props and state. Some things are not a description: listening to the window, starting a timer, telling a server the user arrived. Those belong to an effect, a function React runs after the screen is updated, and cleans up when it is no longer wanted.
When an effect runs
The panel records what happens in an array. The component logs render while it
runs, and its effect logs when React calls it. The effect returns a function, the
cleanup, which React calls before the effect runs again and when the component is
removed. The order is the one in the
event loop lesson of the Node.js course, in spirit:
the work comes after the current job is finished, not in the middle of it.
order.jsx
import { useEffect, act } from 'react';
import { createRoot } from 'react-dom/client';
const log = [];
let seenInEffect = '';
function Hello() {
log.push('render');
useEffect(() => {
log.push('effect');
seenInEffect = stage.innerHTML;
return () => {
log.push('cleanup');
};
}, []);
return <p>hi</p>;
}
const root = createRoot(stage);
await act(async () => root.render(<Hello />));
console.log(log.slice(), seenInEffect);
await act(async () => root.unmount());
console.log(log.slice());
check('render first, then the effect', log.slice(0, 2), ['render', 'effect']);
check('the effect saw the finished DOM', seenInEffect, '<p>hi</p>');
check('the cleanup ran on unmount', log, ['render', 'effect', 'cleanup']);
What node printed (React 19.2.7, development build), when the build ran it
node order.jsx
[ 'render', 'effect' ] <p>hi</p> [ 'render', 'effect', 'cleanup' ] ok render first, then the effect ok the effect saw the finished DOM ok the cleanup ran on unmount
The dependency array decides how often
The second argument says what the effect depends on. With no array it runs after every render.
With [] it runs once, after the first. With [n] it runs after the first
render and again whenever n is different from last time. The panel makes five renders
(the mount, three changes of n, and one change of something else) and counts.
deps.jsx
import { useState, useEffect, act } from 'react';
import { createRoot } from 'react-dom/client';
const runs = { everyRender: 0, once: 0, onN: 0 };
function Probe() {
const [n, setN] = useState(0);
const [other, setOther] = useState(0);
useEffect(() => {
runs.everyRender += 1;
});
useEffect(() => {
runs.once += 1;
}, []);
useEffect(() => {
runs.onN += 1;
}, [n]);
return (
<div>
<button onClick={() => setN(n + 1)}>n {n}</button>
<button onClick={() => setOther(other + 1)}>other {other}</button>
</div>
);
}
const root = createRoot(stage);
await act(async () => root.render(<Probe />));
const [nButton, otherButton] = stage.querySelectorAll('button');
for (let i = 0; i < 3; i++) await act(async () => nButton.click());
await act(async () => otherButton.click());
console.log(runs);
check('no array: after every one of the five renders', runs.everyRender, 5);
check('[]: once', runs.once, 1);
check('[n]: the mount and the three changes of n', runs.onN, 4);
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node deps.jsx
{ everyRender: 5, once: 1, onN: 4 }
ok no array: after every one of the five renders
ok []: once
ok [n]: the mount and the three changes of n
Cleanup is what makes a subscription safe
An effect that subscribes to something outside React must undo it, or every mount leaves a listener behind. The panel adds a listener to the window and removes it in the cleanup, then dispatches an event while the component is on screen and again after it has gone.
listener.jsx
import { useEffect, act } from 'react';
import { createRoot } from 'react-dom/client';
let heard = 0;
function Listener() {
useEffect(() => {
const onPing = () => {
heard += 1;
};
window.addEventListener('ping', onPing);
return () => window.removeEventListener('ping', onPing);
}, []);
return <p>listening</p>;
}
const root = createRoot(stage);
await act(async () => root.render(<Listener />));
window.dispatchEvent(new Event('ping'));
const whileMounted = heard;
await act(async () => root.unmount());
window.dispatchEvent(new Event('ping'));
console.log(whileMounted, heard);
check('it heard the event while mounted', whileMounted, 1);
check('and nothing after the cleanup', heard, 1);
What node printed (React 19.2.7, development build), when the build ran it
node listener.jsx
1 1 ok it heard the event while mounted ok and nothing after the cleanup
Setting state after unmount is silent
Older tutorials warn about setting state on an unmounted component. Current React does not: the call is simply ignored. The panel keeps the setter, unmounts the component, calls the setter, and checks that the stage is still empty. It is a clean panel, which means the build also checked that React printed nothing.
late.jsx
import { useState, act } from 'react';
import { createRoot } from 'react-dom/client';
let setLater;
function Late() {
const [text, setText] = useState('first');
setLater = setText;
return <p>{text}</p>;
}
const root = createRoot(stage);
await act(async () => root.render(<Late />));
const mounted = stage.innerHTML;
await act(async () => root.unmount());
await act(async () => setLater('second'));
console.log(mounted, JSON.stringify(stage.innerHTML));
check('it was on the screen', mounted, '<p>first</p>');
check('the stage stays empty', stage.innerHTML, '');
What node printed (React 19.2.7, development build), when the build ran it
node late.jsx
<p>first</p> "" ok it was on the screen ok the stage stays empty
This means you do not need a flag to guard against it. It also means a mistake can hide: a request that finishes after the component is gone still runs its code, and cleanup is how you stop work that is no longer wanted. The next lesson is about getting the dependency array right.
- An effect runs after the screen is updated, and sees the finished DOM.
- No array: every render.
[]: once.[a, b]: when one of them changed. - Return a cleanup that undoes the effect. React runs it before the next run and on unmount.
- Setting state after unmount is ignored without a warning.
14 Dependencies, stale closures and cleanup
The dependency array is a promise: this effect only needs to run again when these values change. Break the promise and the effect keeps using values from a render that is long gone, which is called a stale closure. This lesson makes one happen, fixes it two ways, and shows the effect that cannot be written as an async function.
A stale closure
Each render makes new functions, and each function remembers the values of its own render. That
is the closure idea again. An effect with
[] runs once, so the function it set up is from the first render and its
count is 0 forever. The panel clicks three times so that state is
3, then dispatches the event the listener is waiting for.
stale.jsx
import { useState, useEffect, act } from 'react';
import { createRoot } from 'react-dom/client';
const logs = [];
function Stale() {
const [count, setCount] = useState(0);
useEffect(() => {
const onTick = () => logs.push(count);
window.addEventListener('tick', onTick);
return () => window.removeEventListener('tick', onTick);
}, []);
return <button onClick={() => setCount(count + 1)}>count {count}</button>;
}
function Fresh() {
const [count, setCount] = useState(0);
useEffect(() => {
const onTick = () => logs.push(count);
window.addEventListener('tick', onTick);
return () => window.removeEventListener('tick', onTick);
}, [count]);
return <button onClick={() => setCount(count + 1)}>count {count}</button>;
}
async function clickThreeThenTick(Component) {
logs.length = 0;
const root = createRoot(stage);
await act(async () => root.render(<Component />));
for (let i = 0; i < 3; i++) await act(async () => stage.querySelector('button').click());
const shown = stage.textContent;
window.dispatchEvent(new Event('tick'));
const result = [shown, logs.slice()];
await act(async () => root.unmount());
return result;
}
const stale = await clickThreeThenTick(Stale);
const fresh = await clickThreeThenTick(Fresh);
console.log(stale, fresh);
check('stale: the screen says 3 but the listener read 0', stale, ['count 3', [0]]);
check('fixed with [count]: the listener is renewed and reads 3', fresh, ['count 3', [3]]);
What node printed (React 19.2.7, development build), when the build ran it
node stale.jsx
[ 'count 3', [ 0 ] ] [ 'count 3', [ 3 ] ] ok stale: the screen says 3 but the listener read 0 ok fixed with [count]: the listener is renewed and reads 3
The fix above is honest: list count and the effect re-runs when it changes. The
cost is that the listener is removed and added each time. When the effect only wants to
change state from what it was, there is a second fix that keeps []: the
updater form from lesson 9, which does not read the closure's value at all.
updater.jsx
import { useState, useEffect, act } from 'react';
import { createRoot } from 'react-dom/client';
function Sticky() {
const [count, setCount] = useState(0);
useEffect(() => {
const onBump = () => setCount(count + 1);
window.addEventListener('bump', onBump);
return () => window.removeEventListener('bump', onBump);
}, []);
return <p>{count}</p>;
}
function Updater() {
const [count, setCount] = useState(0);
useEffect(() => {
const onBump = () => setCount((c) => c + 1);
window.addEventListener('bump', onBump);
return () => window.removeEventListener('bump', onBump);
}, []);
return <p>{count}</p>;
}
async function bumpThree(Component) {
const root = createRoot(stage);
await act(async () => root.render(<Component />));
for (let i = 0; i < 3; i++) await act(async () => window.dispatchEvent(new Event('bump')));
const shown = stage.textContent;
await act(async () => root.unmount());
return shown;
}
const sticky = await bumpThree(Sticky);
const updater = await bumpThree(Updater);
console.log(sticky, updater);
check('count + 1 inside a [] effect sticks at 1', sticky, '1');
check('the updater form reaches 3', updater, '3');
What node printed (React 19.2.7, development build), when the build ran it
node updater.jsx
1 3 ok count + 1 inside a [] effect sticks at 1 ok the updater form reaches 3
Cleanup runs before the next effect
When the dependencies change, React runs the old effect's cleanup first, with the values it was made with, and then the new effect. The log makes the order concrete.
cleanup.jsx
import { useState, useEffect, act } from 'react';
import { createRoot } from 'react-dom/client';
const log = [];
function Tracked() {
const [n, setN] = useState(0);
useEffect(() => {
log.push('effect ' + n);
return () => {
log.push('cleanup ' + n);
};
}, [n]);
return <button onClick={() => setN(n + 1)}>n {n}</button>;
}
const root = createRoot(stage);
await act(async () => root.render(<Tracked />));
const afterMount = log.slice();
await act(async () => stage.querySelector('button').click());
console.log(afterMount, log.slice());
check('after mount', afterMount, ['effect 0']);
check('after a change: cleanup of the old, then the new effect', log, ['effect 0', 'cleanup 0', 'effect 1']);
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node cleanup.jsx
[ 'effect 0' ] [ 'effect 0', 'cleanup 0', 'effect 1' ] ok after mount ok after a change: cleanup of the old, then the new effect
Objects in the array are new every time
React compares dependencies with Object.is, which for objects asks "the same
object?" An object literal written in the component is a brand-new object on every render, so an
effect that depends on it runs every time. Depend on the primitive inside it instead.
object-dep.jsx
import { useState, useEffect, act } from 'react';
import { createRoot } from 'react-dom/client';
const runs = { object: 0, primitive: 0 };
function Deps() {
const [tick, setTick] = useState(0);
const options = { size: 1 };
useEffect(() => {
runs.object += 1;
}, [options]);
useEffect(() => {
runs.primitive += 1;
}, [options.size]);
return <button onClick={() => setTick(tick + 1)}>tick {tick}</button>;
}
const root = createRoot(stage);
await act(async () => root.render(<Deps />));
for (let i = 0; i < 3; i++) await act(async () => stage.querySelector('button').click());
console.log(runs);
check('the object dependency re-ran on every render', runs.object, 4);
check('the primitive ran once', runs.primitive, 1);
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node object-dep.jsx
{ object: 4, primitive: 1 }
ok the object dependency re-ran on every render
ok the primitive ran once
Reading the latest value without depending on it
Sometimes an effect should read a value without re-running when it changes: a
connection that should reconnect when the room changes, but that shows a message in whatever theme
is current. React 19.2 has useEffectEvent for that: a function that always sees the
latest props and state and is not a dependency.
event.jsx
import { useState, useEffect, useEffectEvent, act } from 'react';
import { createRoot } from 'react-dom/client';
const connected = [];
let setTheme;
function Room({ room }) {
const [theme, setThemeState] = useState('light');
setTheme = setThemeState;
const onConnected = useEffectEvent(() => {
connected.push(room + ' in ' + theme);
});
useEffect(() => {
onConnected();
}, [room]);
return <p>{room}</p>;
}
console.log(typeof useEffectEvent);
const root = createRoot(stage);
await act(async () => root.render(<Room room="lobby" />));
await act(async () => setTheme('dark'));
console.log(connected);
check('it is a function', typeof useEffectEvent, 'function');
check('changing the theme did not re-run the effect', connected, ['lobby in light']);
await act(async () => root.render(<Room room="garden" />));
check('a new room did, and the call saw the latest theme', connected, ['lobby in light', 'garden in dark']);
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node event.jsx
function [ 'lobby in light' ] ok it is a function ok changing the theme did not re-run the effect ok a new room did, and the call saw the latest theme
An effect cannot be async
It is natural to write useEffect(async () => { ... }) to use await. An
async function always returns a Promise, and React reads what an effect returns as its cleanup. The
warning appears when the effect runs; the failure comes later, when React unmounts the component and
tries to call the Promise as a function. The panel renders, checks the render worked, and then
unmounts.
async-effect.jsx
import { useEffect, act } from 'react';
import { createRoot } from 'react-dom/client';
function Loader() {
useEffect(async () => {
await Promise.resolve();
}, []);
return <p>loaded</p>;
}
const root = createRoot(stage);
await act(async () => root.render(<Loader />));
check('the render itself worked', stage.innerHTML, '<p>loaded</p>');
await act(async () => root.unmount());
What node printed as it died (React 19.2.7, development build), when the build ran it
node async-effect.jsx
useEffect must not return anything besides a function, which is used for clean-up.
It looks like you wrote useEffect(async () => ...) or returned a Promise. Instead, write the async function inside your effect and call it immediately:
useEffect(() => {
async function fetchData() {
// You can await here
const response = await MyAPI.getData(someId);
// ...
}
fetchData();
}, [someId]); // Or [] if effect doesn't need props or state
Learn more about data fetching with Hooks: https://react.dev/link/hooks-data-fetching
ok the render itself worked
/app/node_modules/react-dom/cjs/react-dom-client.development.js:26001
destroy();
^
TypeError: destroy is not a function
at Object.react_stack_bottom_frame (/app/node_modules/react-dom/cjs/react-dom-client.development.js:26001:13)
at runWithFiberInDEV (/app/node_modules/react-dom/cjs/react-dom-client.development.js:874:13)
at commitHookEffectListUnmount (/app/node_modules/react-dom/cjs/react-dom-client.development.js:13316:17)
at commitHookPassiveUnmountEffects (/app/node_modules/react-dom/cjs/react-dom-client.development.js:13347:11)
at commitPassiveUnmountEffectsInsideOfDeletedTree_begin (/app/node_modules/react-dom/cjs/react-dom-client.development.js:16264:13)
at recursivelyTraversePassiveUnmountEffects (/app/node_modules/react-dom/cjs/react-dom-client.development.js:16085:13)
at commitPassiveUnmountOnFiber (/app/node_modules/react-dom/cjs/react-dom-client.development.js:16127:11)
at flushPassiveEffects (/app/node_modules/react-dom/cjs/react-dom-client.development.js:18428:9)
at flushPendingEffects (/app/node_modules/react-dom/cjs/react-dom-client.development.js:18358:14)
at flushSpawnedWork (/app/node_modules/react-dom/cjs/react-dom-client.development.js:18323:44)
Node.js v26.7.0
The fix is to keep the effect itself synchronous and start the async work inside it. Return a cleanup that says the answer is no longer wanted, so a late reply is ignored.
fixed-effect.jsx
import { useEffect, useState, act } from 'react';
import { createRoot } from 'react-dom/client';
function Loader() {
const [text, setText] = useState('loading');
useEffect(() => {
let wanted = true;
async function load() {
const value = await Promise.resolve('loaded');
if (wanted) setText(value);
}
load();
return () => {
wanted = false;
};
}, []);
return <p>{text}</p>;
}
const root = createRoot(stage);
await act(async () => root.render(<Loader />));
console.log(stage.innerHTML);
check('the async work finished and the screen shows it', stage.innerHTML, '<p>loaded</p>');
await act(async () => root.unmount());
check('unmounting is clean', stage.innerHTML, '');
What node printed (React 19.2.7, development build), when the build ran it
node fixed-effect.jsx
<p>loaded</p> ok the async work finished and the screen shows it ok unmounting is clean
- An effect reads the values of the render it was made in. List what it uses.
- A stale closure is a missing dependency. Fix it with the dependency, or the updater form.
- Cleanup of the old effect runs before the new one.
- An object or array written in the component is new every render; depend on its parts.
useEffectEventreads the latest values without being a dependency.- Never pass an async function to
useEffect; call one from inside.
15 StrictMode's double run, dev against production
Your project's entry file almost certainly wraps the app in
<StrictMode>. In development it makes React do things twice on purpose, and
people who do not know that think React is broken. This lesson shows exactly what is doubled, why,
and that none of it happens in production.
What StrictMode does
In development, inside <StrictMode>, React:
- calls your component function twice per render and throws one result away, to expose code that is not pure;
- calls state initialisers and reducers twice, for the same reason;
- after mounting, runs each effect's set-up, then its cleanup, then its set-up again, to check that the cleanup really undoes the set-up.
The component below counts its renders and effects and logs each effect and cleanup in an array. It is the same file in both panels of this lesson: here in the development build, further down in production.
Counter.jsx
import { useState, useEffect } from 'react';
export const stats = { renders: 0, effects: 0, sequence: [] };
export function Counter() {
const [count, setCount] = useState(0);
stats.renders += 1;
useEffect(() => {
stats.effects += 1;
stats.sequence.push('effect ' + count);
return () => {
stats.sequence.push('cleanup ' + count);
};
}, [count]);
return <button onClick={() => setCount(count + 1)}>clicked {count}</button>;
}
main.jsx
import { StrictMode, act } from 'react';
import { createRoot } from 'react-dom/client';
import { Counter, stats } from './Counter.jsx';
const root = createRoot(stage);
await act(async () => root.render(<StrictMode><Counter /></StrictMode>));
console.log('renders ' + stats.renders + ' effects ' + stats.effects);
console.log(stats.sequence.slice());
check('renders 2 effects 2', 'renders ' + stats.renders + ' effects ' + stats.effects, 'renders 2 effects 2');
check('mount: set-up, clean-up, set-up', stats.sequence, ['effect 0', 'cleanup 0', 'effect 0']);
await act(async () => stage.querySelector('button').click());
console.log(stats.sequence.slice());
check('after a click, the old effect is cleaned up and the new one set up',
stats.sequence, ['effect 0', 'cleanup 0', 'effect 0', 'cleanup 0', 'effect 1']);
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node main.jsx
renders 2 effects 2 [ 'effect 0', 'cleanup 0', 'effect 0' ] ok renders 2 effects 2 ok mount: set-up, clean-up, set-up [ 'effect 0', 'cleanup 0', 'effect 0', 'cleanup 0', 'effect 1' ] ok after a click, the old effect is cleaned up and the new one set up
Two renders, two effects: React rendered, ran the effect, cleaned it up and ran it again. The second run is the point. If the effect opened a connection, the cleanup must close it, or the doubled run would leave two open, and you find out on your machine instead of in front of a user. A click shows the ordinary sequence once the extra check is over.
Initialisers and reducers run twice too
A state initialiser or a reducer that changes something outside itself, or reads something that changes, is not pure. Running it twice makes the difference visible. The panel counts the calls.
pure.jsx
import { StrictMode, useState, useReducer, act } from 'react';
import { createRoot } from 'react-dom/client';
let initCalls = 0;
let reducerCalls = 0;
let dispatchLater;
function reducer(state) {
reducerCalls += 1;
return state + 1;
}
function Pure() {
const [start] = useState(() => {
initCalls += 1;
return 5;
});
const [total, dispatch] = useReducer(reducer, start);
dispatchLater = dispatch;
return <p>{total}</p>;
}
const root = createRoot(stage);
await act(async () => root.render(<StrictMode><Pure /></StrictMode>));
const initAfterMount = initCalls;
await act(async () => dispatchLater());
console.log(initAfterMount, reducerCalls, stage.textContent);
check('the initialiser ran twice for one mount', initAfterMount, 2);
check('the reducer ran twice for one dispatch', reducerCalls, 2);
check('and the result counts the dispatch once', stage.textContent, '6');
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node pure.jsx
2 2 6 ok the initialiser ran twice for one mount ok the reducer ran twice for one dispatch ok and the result counts the dispatch once
Neither call changed the outcome, because both are pure: the extra run was thrown away. That is the reason reducers must be pure, and the reason lesson 19 comes back to this.
Production does none of it
StrictMode costs nothing in production: it renders its children and does nothing else. The
panel below is declared as a production panel and uses the same Counter.jsx,
shown above, so nothing differs but the build. There is one more difference to handle. The
production build has no act, so the script cannot ask React to finish. It uses
flushSync, which renders synchronously, and waits one timer tick for the effects,
which React runs after the screen is updated.
main.jsx
import * as React from 'react';
import { StrictMode } from 'react';
import { flushSync } from 'react-dom';
import { createRoot } from 'react-dom/client';
import { Counter, stats } from './Counter.jsx';
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
const root = createRoot(stage);
flushSync(() => root.render(<StrictMode><Counter /></StrictMode>));
await tick();
console.log('renders ' + stats.renders + ' effects ' + stats.effects);
check('renders 1 effects 1', 'renders ' + stats.renders + ' effects ' + stats.effects, 'renders 1 effects 1');
check('mount: one set-up and nothing else', stats.sequence, ['effect 0']);
stage.querySelector('button').click();
await tick();
console.log(stats.sequence.slice());
check('after a click, the ordinary sequence', stats.sequence, ['effect 0', 'cleanup 0', 'effect 1']);
check('act does not exist in the production build', typeof React.act, 'undefined');
check('and this really is the production build', process.env.NODE_ENV, 'production');
flushSync(() => root.unmount());
await tick();
What node printed (React 19.2.7, production build), when the build ran it
NODE_ENV=production node main.jsx
renders 1 effects 1 ok renders 1 effects 1 ok mount: one set-up and nothing else [ 'effect 0', 'cleanup 0', 'effect 1' ] ok after a click, the ordinary sequence ok act does not exist in the production build ok and this really is the production build
The same file, one render and one effect. StrictMode is a development tool: it finds problems and leaves nothing behind, so your users never see a double run. If a count surprises you, the first question to ask is whether StrictMode is on.
- In development, StrictMode renders twice and runs each effect's set-up, clean-up, set-up on mount.
- State initialisers and reducers run twice. If that changes the result, they are not pure.
- A correct effect has a cleanup that makes the extra run harmless.
- Production does none of it, and has no
act; useflushSyncand wait a tick.
16 You might not need an effect
Lesson 13 taught the effect, and the temptation is to use it for everything that has to happen "after" something. Most of those things should not be effects. An effect is for talking to the world outside React. For everything inside it there is a simpler tool, and the effect version is slower in a way you can count.
Derive it; do not copy it
If a value can be worked out from props and state, work it out while rendering (lesson 12). The tempting alternative stores it in more state and keeps it up to date with an effect. The panel renders both, changes the first name once, and records the first name and full name that each render saw.
derive.jsx
import { useState, useEffect, act } from 'react';
import { createRoot } from 'react-dom/client';
let seen = [];
function Derived() {
const [first, setFirst] = useState('Ada');
const full = first + ' Lovelace';
seen.push(first + '|' + full);
return <button onClick={() => setFirst('Grace')}>{full}</button>;
}
function Mirrored() {
const [first, setFirst] = useState('Ada');
const [full, setFull] = useState('');
useEffect(() => {
setFull(first + ' Lovelace');
}, [first]);
seen.push(first + '|' + full);
return <button onClick={() => setFirst('Grace')}>{full}</button>;
}
async function change(Component) {
seen = [];
const root = createRoot(stage);
await act(async () => root.render(<Component />));
const mounted = seen.length;
await act(async () => stage.querySelector('button').click());
const result = { mountRenders: mounted, changeRenders: seen.length - mounted, seen: seen.slice(mounted), text: stage.textContent };
await act(async () => root.unmount());
return result;
}
const derived = await change(Derived);
const mirrored = await change(Mirrored);
console.log(derived);
console.log(mirrored);
check('derived: mounting took one render', derived.mountRenders, 1);
check('derived: a change took one render', derived.changeRenders, 1);
check('derived: and the screen was right at once', derived.seen, ['Grace|Grace Lovelace']);
check('mirrored: mounting took two, because the effect set state', mirrored.mountRenders, 2);
check('mirrored: a change took two renders', mirrored.changeRenders, 2);
check('mirrored: the first of them showed the old full name', mirrored.seen, ['Grace|Ada Lovelace', 'Grace|Grace Lovelace']);
check('both end on the same text', [derived.text, mirrored.text], ['Grace Lovelace', 'Grace Lovelace']);
What node printed (React 19.2.7, development build), when the build ran it
node derive.jsx
{
mountRenders: 1,
changeRenders: 1,
seen: [ 'Grace|Grace Lovelace' ],
text: 'Grace Lovelace'
}
{
mountRenders: 2,
changeRenders: 2,
seen: [ 'Grace|Ada Lovelace', 'Grace|Grace Lovelace' ],
text: 'Grace Lovelace'
}
ok derived: mounting took one render
ok derived: a change took one render
ok derived: and the screen was right at once
ok mirrored: mounting took two, because the effect set state
ok mirrored: a change took two renders
ok mirrored: the first of them showed the old full name
ok both end on the same text
The mirrored version is correct in the end and wrong for one render in the middle: it drew
the new first name beside the old full name, then drew again. It also did twice the work. When you
notice an effect whose only job is setSomething(...) from props or state, delete
it and compute the value.
A click belongs in an event handler
If something should happen because the user did something, put it in the handler for what they did. An effect that reacts to the state that the click changed also runs when the component first appears, which you did not want, and it cannot tell why the state changed.
handler.jsx
import { useState, useEffect, act } from 'react';
import { createRoot } from 'react-dom/client';
const sent = [];
function ByEffect() {
const [count, setCount] = useState(0);
useEffect(() => {
sent.push('effect ' + count);
}, [count]);
return <button onClick={() => setCount(count + 1)}>add</button>;
}
function ByHandler() {
const [count, setCount] = useState(0);
function handleClick() {
sent.push('handler ' + (count + 1));
setCount(count + 1);
}
return <button onClick={handleClick}>add {count}</button>;
}
const root = createRoot(stage);
await act(async () => root.render(<ByEffect />));
const effectBefore = sent.length;
await act(async () => stage.querySelector('button').click());
const effectAfter = sent.length;
await act(async () => root.render(<ByHandler />));
const handlerBefore = sent.length - effectAfter;
await act(async () => stage.querySelector('button').click());
console.log(sent);
check('the effect reported once before any click', effectBefore, 1);
check('and twice after one', effectAfter, 2);
check('the handler reported nothing before a click', handlerBefore, 0);
check('and once after', sent.slice(effectAfter), ['handler 1']);
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node handler.jsx
[ 'effect 0', 'effect 1', 'handler 1' ] ok the effect reported once before any click ok and twice after one ok the handler reported nothing before a click ok and once after
Reset state with a key
When a component should start fresh because "which one" changed (a different person's profile,
a different document), you do not need an effect that clears its state. Give it a key.
A different key means a different component to React: the old one is removed and a new one is
made, with new state and a new DOM node. The same key with different props is the same component,
and it keeps its state.
reset.jsx
import { useState, act } from 'react';
import { createRoot } from 'react-dom/client';
function Draft({ id }) {
const [edits, setEdits] = useState(0);
return <button className="add" onClick={() => setEdits(edits + 1)}>{id}: {edits}</button>;
}
function Page({ id, keyed }) {
return keyed ? <Draft key={id} id={id} /> : <Draft id={id} />;
}
async function switchProfile(keyed) {
const root = createRoot(stage);
await act(async () => root.render(<Page id="ada" keyed={keyed} />));
const first = stage.querySelector('.add');
await act(async () => first.click());
await act(async () => root.render(<Page id="grace" keyed={keyed} />));
const second = stage.querySelector('.add');
const result = { text: second.textContent, sameNode: second === first };
await act(async () => root.unmount());
return result;
}
const plain = await switchProfile(false);
const keyed = await switchProfile(true);
console.log(plain, keyed);
check('same component, new prop: state carries over', plain, { text: 'grace: 1', sameNode: true });
check('new key: state reset and a new DOM node', keyed, { text: 'grace: 0', sameNode: false });
What node printed (React 19.2.7, development build), when the build ran it
node reset.jsx
{ text: 'grace: 1', sameNode: true } { text: 'grace: 0', sameNode: false }
ok same component, new prop: state carries over
ok new key: state reset and a new DOM node
Whether to use an effect comes down to one question: is this about the outside world, or about the screen? A subscription, a timer or a request is the outside world. Anything you can work out from state, anything a click causes and any reset are about the screen, and have a simpler home.
- If you can compute it from props and state, compute it in render. Do not mirror it with an effect.
- Code that runs because the user did something belongs in the event handler.
- To reset a component's state, change its
key. - Effects are for the outside world: subscriptions, timers, requests, the document.
17 Refs: useRef, DOM refs, and ref as a prop
State is how a component remembers something that should change what is on screen. Sometimes a component must remember something that should not: a timer id, how many times it has rendered. And sometimes it must reach the real DOM element itself, to focus it or to read it. A ref is the tool for both.
A box that React does not watch
useRef(initial) returns an object, { current: initial }, and returns
the same object on every render. Changing current is just changing a
property: it does not cause a render, and nothing on the screen moves. That is the opposite of
state, and the reason to choose one over the other.
box.jsx
import { useRef, useState, act } from 'react';
import { createRoot } from 'react-dom/client';
let renders = 0;
let box;
function Clicker() {
const clicks = useRef(0);
const [shown, setShown] = useState(0);
box = clicks;
renders += 1;
return (
<div>
<button id="hidden" onClick={() => { clicks.current += 1; }}>count in a ref</button>
<button id="visible" onClick={() => setShown(shown + 1)}>shown {shown}</button>
</div>
);
}
const root = createRoot(stage);
await act(async () => root.render(<Clicker />));
const before = stage.textContent;
for (let i = 0; i < 3; i++) await act(async () => stage.querySelector('#hidden').click());
console.log(box.current, renders);
check('the ref holds the three clicks', box.current, 3);
check('and nothing rendered', renders, 1);
check('the screen is unchanged', stage.textContent, before);
await act(async () => stage.querySelector('#visible').click());
check('state does render', renders, 2);
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node box.jsx
3 1 ok the ref holds the three clicks ok and nothing rendered ok the screen is unchanged ok state does render
A ref to a DOM element
Put a ref on an element and React sets ref.current to the real DOM node once it
exists, and back to null when it is removed. During the first render it is still
null, so you read it in an effect or an event handler. The usual use is
focus(). The panel checks which element it got, and that the document says that
element has focus.
focus.jsx
import { useRef, useEffect, act } from 'react';
import { createRoot } from 'react-dom/client';
const seen = {};
function Search() {
const inputRef = useRef(null);
seen.ref = inputRef;
seen.duringRender = inputRef.current;
useEffect(() => {
seen.tagName = inputRef.current.tagName;
inputRef.current.focus();
}, []);
return <input ref={inputRef} aria-label="Search" />;
}
const root = createRoot(stage);
await act(async () => root.render(<Search />));
const input = stage.querySelector('input');
console.log(seen.duringRender, seen.tagName, document.activeElement === input);
check('during the first render the ref is empty', seen.duringRender, null);
check('after mount it is the element', seen.tagName, 'INPUT');
check('focus() in the effect worked', document.activeElement === input, true);
await act(async () => root.unmount());
check('the ref is null again once the element is removed', seen.ref.current, null);
What node printed (React 19.2.7, development build), when the build ran it
node focus.jsx
null INPUT true ok during the first render the ref is empty ok after mount it is the element ok focus() in the effect worked ok the ref is null again once the element is removed
ref is a prop
Until React 19, a function component could not receive ref: you wrapped it in
forwardRef. Now ref arrives with the other props, and you hand it on to
the element that should have it. A parent can reach an input inside a component it does not
control the markup of.
prop.jsx
import { useRef, useEffect, act } from 'react';
import { createRoot } from 'react-dom/client';
function Field({ label, ref }) {
return (
<label>
{label}
<input ref={ref} />
</label>
);
}
function Form() {
const fieldRef = useRef(null);
useEffect(() => {
fieldRef.current.focus();
}, []);
return <Field label="Name" ref={fieldRef} />;
}
const root = createRoot(stage);
await act(async () => root.render(<Form />));
const input = stage.querySelector('input');
console.log(document.activeElement === input);
check('the parent focused the input inside the child', document.activeElement === input, true);
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node prop.jsx
true ok the parent focused the input inside the child
A ref callback can clean up
Instead of an object, a ref can be a function. React calls it with the element when it appears. From React 19 the function may return a cleanup, which React calls when the element goes away, like an effect. Define the function outside the component, or it is a new function every render and React would call it again each time.
callback.jsx
import { act } from 'react';
import { createRoot } from 'react-dom/client';
const order = [];
function attach(node) {
order.push('attach ' + node.tagName);
return () => {
order.push('detach');
};
}
function Tracked() {
return <input ref={attach} />;
}
const root = createRoot(stage);
await act(async () => root.render(<Tracked />));
const mounted = order.slice();
await act(async () => root.unmount());
console.log(mounted, order);
check('attached on mount', mounted, ['attach INPUT']);
check('the cleanup ran on unmount, and the callback was not called again', order, ['attach INPUT', 'detach']);
What node printed (React 19.2.7, development build), when the build ran it
node callback.jsx
[ 'attach INPUT' ] [ 'attach INPUT', 'detach' ] ok attached on mount ok the cleanup ran on unmount, and the callback was not called again
When to reach for a ref. Use state for anything that should change what is on
the screen. Use a ref for a value React does not need to know about, and for the few things only a
DOM node can do: focus, scroll, playing a video, measuring. Do not read or write
ref.current while rendering; do it in handlers and effects.
useRefgives the same{ current }object every render; changing it does not render.- A DOM ref is
nulluntil the element exists. Read it in an effect or a handler. - In React 19
refis an ordinary prop on a function component; noforwardRef. - A ref callback may return a cleanup that runs when the element is removed.