Emmanuel Akinfulubi

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.

Render, effect, cleanup

ran at build time · in happy-dom 20.11.6 · React dev

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.

Counting the runs

ran at build time · in happy-dom 20.11.6 · React dev

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.

A subscription and its cleanup

ran at build time · in happy-dom 20.11.6 · React dev

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.

A setter called after unmount

ran at build time · in happy-dom 20.11.6 · React dev

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.

An effect that never re-runs

ran at build time · in happy-dom 20.11.6 · React dev

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.

The updater form needs no dependency

ran at build time · in happy-dom 20.11.6 · React dev

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.

The order of cleanup and effect

ran at build time · in happy-dom 20.11.6 · React dev

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.

An object dependency

ran at build time · in happy-dom 20.11.6 · React dev

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.

useEffectEvent

ran at build time · in happy-dom 20.11.6 · React dev

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.

An async effect, unmounted

dies with TypeError · in happy-dom 20.11.6 · React dev

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.

The fix: an async function inside

ran at build time · in happy-dom 20.11.6 · React dev

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.
  • useEffectEvent reads 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.

Development: everything twice

ran at build time · in happy-dom 20.11.6 · React dev

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.

Initialisers and reducers, twice

ran at build time · in happy-dom 20.11.6 · React dev

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.

Production: the same Counter, once

ran at build time · in happy-dom 20.11.6 · React prod

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; use flushSync and 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.

Computed in render against copied by an effect

ran at build time · in happy-dom 20.11.6 · React dev

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.

Reacting to a click

ran at build time · in happy-dom 20.11.6 · React dev

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.

A key resets state

ran at build time · in happy-dom 20.11.6 · React dev

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.

Changing a ref does not render

ran at build time · in happy-dom 20.11.6 · React dev

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.

A DOM ref, and focus

ran at build time · in happy-dom 20.11.6 · React dev

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.

A ref passed as a prop

ran at build time · in happy-dom 20.11.6 · React dev

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.

A ref callback with a cleanup

ran at build time · in happy-dom 20.11.6 · React dev

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.

  • useRef gives the same { current } object every render; changing it does not render.
  • A DOM ref is null until the element exists. Read it in an effect or a handler.
  • In React 19 ref is an ordinary prop on a function component; no forwardRef.
  • A ref callback may return a cleanup that runs when the element is removed.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.