Emmanuel Akinfulubi

State and events all 5 written

Events in a DOM, state as a snapshot per render, updating objects and arrays without mutating them, forms, and sharing state.

8 Events, in a DOM

A page does things when people click, type and submit. In React you pass a function to the element that should hear it, and React calls the function with an event. This lesson drives real clicks and a real submit and checks exactly what the functions saw.

A click, and what the handler receives

The prop is onClick, in camel case, and the value is a function. React calls it with a synthetic event: an object that looks like the browser's, with the same type, target and preventDefault(), and the same shape in every browser. currentTarget is the element the handler is attached to.

The panel uses act. React does not run a click's updates the instant they are asked for: it batches them and finishes soon after. await act(async () => ...) runs the callback and then waits until React has finished all of it, so the next line sees the result. Real pages never need it, because the browser keeps running; a script that must look at the outcome right away does.

What a handler receives

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

press.jsx

import { act } from 'react';
import { createRoot } from 'react-dom/client';

const seen = [];

function Press() {
  function handleClick(e) {
    seen.push(e.type, e.currentTarget.tagName, e.target === e.currentTarget);
  }
  return <button onClick={handleClick}>Press</button>;
}

const root = createRoot(stage);
await act(async () => root.render(<Press />));
await act(async () => stage.querySelector('button').click());
console.log(seen);

check('the handler ran once, with a click event on the button', seen, ['click', 'BUTTON', true]);
await act(async () => root.unmount());

What node printed (React 19.2.7, development build), when the build ran it

node press.jsx

[ 'click', 'BUTTON', true ]
ok   the handler ran once, with a click event on the button

Pass the function, do not call it

The commonest mistake with events is a pair of parentheses. onClick={handle} hands React the function to call later. onClick={handle()} calls it right now, while the component renders, and hands React whatever it returned. The count of calls shows which is which.

onClick={handle()} against onClick={handle}

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

call.jsx

import { act } from 'react';
import { createRoot } from 'react-dom/client';

let calls = 0;
function handle() {
  calls += 1;
}

function Wrong() {
  return <button onClick={handle()}>wrong</button>;
}

function Right() {
  return <button onClick={handle}>right</button>;
}

const root = createRoot(stage);

await act(async () => root.render(<Wrong />));
const afterWrongRender = calls;
await act(async () => stage.querySelector('button').click());
const afterWrongClick = calls;

await act(async () => root.render(<Right />));
const afterRightRender = calls;
await act(async () => stage.querySelector('button').click());
console.log([afterWrongRender, afterWrongClick, afterRightRender, calls]);

check('handle() ran during the render', afterWrongRender, 1);
check('and the click did nothing', afterWrongClick, 1);
check('handle did not run when Right rendered', afterRightRender, 1);
check('it ran once for the click', calls, 2);
await act(async () => root.unmount());

What node printed (React 19.2.7, development build), when the build ran it

node call.jsx

[ 1, 1, 1, 2 ]
ok   handle() ran during the render
ok   and the click did nothing
ok   handle did not run when Right rendered
ok   it ran once for the click

Bubbling, stopping it, and capturing

A click on a button is also a click on everything that contains it. Handlers run from the innermost element outwards, which is called bubbling. A child can end that with e.stopPropagation(). A handler named with Capture on the end, such as onClickCapture, runs on the way in, before the child's. The panel records the order each time in an array.

The order handlers run in

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

order.jsx

import { act } from 'react';
import { createRoot } from 'react-dom/client';

const log = [];

function Tree({ stop, capture }) {
  return (
    <div
      onClick={() => log.push('parent')}
      onClickCapture={capture ? () => log.push('parent-capture') : undefined}
    >
      <button
        onClick={(e) => {
          log.push('child');
          if (stop) e.stopPropagation();
        }}
      >
        go
      </button>
    </div>
  );
}

const root = createRoot(stage);

async function press(props) {
  log.length = 0;
  await act(async () => root.render(<Tree {...props} />));
  await act(async () => stage.querySelector('button').click());
  return log.slice();
}

const bubbled = await press({});
const stopped = await press({ stop: true });
const captured = await press({ capture: true });
console.log(bubbled, stopped, captured);

check('the child runs first, then the parent', bubbled, ['child', 'parent']);
check('stopPropagation ends it at the child', stopped, ['child']);
check('a capture handler runs before the child', captured, ['parent-capture', 'child', 'parent']);
await act(async () => root.unmount());

What node printed (React 19.2.7, development build), when the build ran it

node order.jsx

[ 'child', 'parent' ] [ 'child' ] [ 'parent-capture', 'child', 'parent' ]
ok   the child runs first, then the parent
ok   stopPropagation ends it at the child
ok   a capture handler runs before the child

A form, and preventDefault

Submitting a form is an event too, and the browser's default response is to load a new page. A React form handles it itself, so the first line of the handler is e.preventDefault(). The panel submits with requestSubmit(), which fires the same submit event a click on the button would, and then reads e.defaultPrevented.

Submitting a form

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

signup.jsx

import { useState, act } from 'react';
import { createRoot } from 'react-dom/client';

const seen = [];

function Signup() {
  const [status, setStatus] = useState('waiting');

  function handleSubmit(e) {
    e.preventDefault();
    seen.push(e.type, e.defaultPrevented);
    setStatus('sent');
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" defaultValue="ada@example.com" />
      <button>Join</button>
      <p>{status}</p>
    </form>
  );
}

const root = createRoot(stage);
await act(async () => root.render(<Signup />));
const before = stage.querySelector('p').textContent;
await act(async () => stage.querySelector('form').requestSubmit());
console.log(before, stage.querySelector('p').textContent, seen);

check('the status before', before, 'waiting');
check('the handler saw a prevented submit', seen, ['submit', true]);
check('and set the status', stage.querySelector('p').textContent, 'sent');
await act(async () => root.unmount());

What node printed (React 19.2.7, development build), when the build ran it

node signup.jsx

waiting sent [ 'submit', true ]
ok   the status before
ok   the handler saw a prevented submit
ok   and set the status

To submit a form from a script, use requestSubmit(): it fires the same submit event a person pressing the button would, so React's handler runs.

  • Pass a function: onClick={handle}. With parentheses it runs during the render.
  • The handler gets a synthetic event: type, target, currentTarget, preventDefault().
  • Events bubble from the child outwards; stopPropagation() stops it; Capture handlers run first.
  • A form's onSubmit starts with e.preventDefault().
  • act waits until React has finished, so a script can look at the result.

9 useState: a snapshot per render

A component that only reads props shows the same thing for the same input. To remember something between renders, such as a count or what someone typed, a component needs state. useState gives it one value and a function to ask for a new one, and the way those two behave is the idea this whole part of the course rests on.

State, and the update that is not immediate

const [count, setCount] = useState(0) returns the current value and a setter. Calling the setter does not change count in the code that is running. It asks React to render the component again with a new value. The next three lines show the consequence. The first button calls setCount(count + 1) three times, and all three calls read the same count, so they all ask for the same next value. The second uses the updater form, setCount(n => n + 1), which receives the latest pending value, so the three calls add up. The panel also counts how many times the component function ran. This is the closure idea from the Node.js course: each render's handlers close over that render's values.

count + 1 against the updater form

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

counter.jsx

import { useState, act } from 'react';
import { createRoot } from 'react-dom/client';

let renders = 0;

function Counter() {
  const [plain, setPlain] = useState(0);
  const [updater, setUpdater] = useState(0);
  renders += 1;

  function addThreePlain() {
    setPlain(plain + 1);
    setPlain(plain + 1);
    setPlain(plain + 1);
  }

  function addThreeUpdater() {
    setUpdater((n) => n + 1);
    setUpdater((n) => n + 1);
    setUpdater((n) => n + 1);
  }

  return (
    <div>
      <button onClick={addThreePlain}>plain {plain}</button>
      <button onClick={addThreeUpdater}>updater {updater}</button>
    </div>
  );
}

const root = createRoot(stage);
await act(async () => root.render(<Counter />));
const afterMount = renders;
const [plainButton, updaterButton] = stage.querySelectorAll('button');

await act(async () => plainButton.click());
const afterPlain = renders;
console.log(plainButton.textContent, afterPlain);

await act(async () => updaterButton.click());
console.log(updaterButton.textContent, renders);

check('one render to mount', afterMount, 1);
check('three set calls with count + 1 add one', plainButton.textContent, 'plain 1');
check('and cost one render, not three', afterPlain, 2);
check('three updater calls add three', updaterButton.textContent, 'updater 3');
await act(async () => root.unmount());

What node printed (React 19.2.7, development build), when the build ran it

node counter.jsx

plain 1 2
updater 3 3
ok   one render to mount
ok   three set calls with count + 1 add one
ok   and cost one render, not three
ok   three updater calls add three

Each render sees one value

The value read straight after calling the setter is still the old one: count is a constant for that render. A handler made during an earlier render keeps the value it was made with, even after the screen has moved on. The panel keeps the first render's handler, clicks, and then calls it.

A snapshot per render

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

snapshot.jsx

import { useState, act } from 'react';
import { createRoot } from 'react-dom/client';

const readings = [];
let firstRender;

function Snapshot() {
  const [count, setCount] = useState(0);
  const readCount = () => count;
  if (!firstRender) firstRender = readCount;

  function handleClick() {
    setCount(count + 1);
    readings.push(count);
  }

  return <button onClick={handleClick}>count {count}</button>;
}

const root = createRoot(stage);
await act(async () => root.render(<Snapshot />));
await act(async () => stage.querySelector('button').click());
console.log(stage.textContent, readings, firstRender());

check('the screen shows the new value', stage.textContent, 'count 1');
check('the value read right after the set was the old one', readings, [0]);
check('and the first render still sees its own value', firstRender(), 0);
await act(async () => root.unmount());

What node printed (React 19.2.7, development build), when the build ran it

node snapshot.jsx

count 1 [ 0 ] 0
ok   the screen shows the new value
ok   the value read right after the set was the old one
ok   and the first render still sees its own value

State belongs to one place in the tree

State is not a global variable. It lives with one instance of the component, at one position in the tree. Two copies of the same component each have their own.

Two instances, two states

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

instances.jsx

import { useState, act } from 'react';
import { createRoot } from 'react-dom/client';

function Likes({ name }) {
  const [likes, setLikes] = useState(0);
  return <button onClick={() => setLikes(likes + 1)}>{name} {likes}</button>;
}

const root = createRoot(stage);
await act(async () => root.render(<><Likes name="a" /><Likes name="b" /></>));
const buttons = stage.querySelectorAll('button');

await act(async () => buttons[0].click());
await act(async () => buttons[0].click());
await act(async () => buttons[1].click());
const texts = Array.from(buttons).map((b) => b.textContent);
console.log(texts);

check('each instance counted its own clicks', texts, ['a 2', 'b 1']);
await act(async () => root.unmount());

What node printed (React 19.2.7, development build), when the build ran it

node instances.jsx

[ 'a 2', 'b 1' ]
ok   each instance counted its own clicks

An initial value that is expensive to make

useState(0) uses its argument only on the first render. If the argument is a function call, though, the call itself still happens on every render and its result is thrown away. Pass a function instead and React calls it once, when it needs the first value.

A lazy initial value

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

lazy.jsx

import { useState, act } from 'react';
import { createRoot } from 'react-dom/client';

let eagerCalls = 0;
let lazyCalls = 0;

function eager() {
  eagerCalls += 1;
  return 10;
}

function lazy() {
  lazyCalls += 1;
  return 10;
}

function Start() {
  const [a, setA] = useState(eager());
  const [b] = useState(() => lazy());
  return <button onClick={() => setA(a + 1)}>{a} {b}</button>;
}

const root = createRoot(stage);
await act(async () => root.render(<Start />));
await act(async () => stage.querySelector('button').click());
await act(async () => stage.querySelector('button').click());
console.log(stage.textContent, eagerCalls, lazyCalls);

check('the state moved on', stage.textContent, '12 10');
check('the eager call ran on all three renders', eagerCalls, 3);
check('the lazy initialiser ran once', lazyCalls, 1);
await act(async () => root.unmount());

What node printed (React 19.2.7, development build), when the build ran it

node lazy.jsx

12 10 3 1
ok   the state moved on
ok   the eager call ran on all three renders
ok   the lazy initialiser ran once

Without StrictMode the lazy function runs exactly once. Lesson 15 turns StrictMode on and shows React deliberately calling it twice in development, to catch initialisers that are not pure.

  • useState(initial) returns [value, setValue]. Setting asks for another render.
  • Within one render the value is a fixed snapshot; a set call does not change it.
  • Several sets in one event batch into one render. Use setX(n => n + 1) to build on the latest value.
  • State belongs to one instance in the tree; two copies each keep their own.
  • Pass a function, useState(() => make()), when making the first value is costly.

10 Updating objects and arrays without mutating

Lesson 9 said that setting state asks for a new render. This lesson is about what you give it. React finds out whether state changed by asking one question: is this the same object as last time? If you change an object and hand back the same one, the answer is yes, and nothing happens.

Changing state in place does nothing, then does too much

The first button edits the object that is in state and sets it back. The second builds a new object with the object spread, { ...user, city: 'Paris' }, which copies every property and replaces one. Objects and their spread are in the Node.js course's lesson on objects. The panel prints the text and counts the renders after each click.

Mutating against replacing

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

profile.jsx

import { useState, act } from 'react';
import { createRoot } from 'react-dom/client';

let renders = 0;

function Profile() {
  const [user, setUser] = useState({ name: 'Ada', city: 'London' });
  renders += 1;

  function mutate() {
    user.name = 'Grace';
    setUser(user);
  }

  function replace() {
    setUser({ ...user, city: 'Paris' });
  }

  return (
    <div>
      <button onClick={mutate}>mutate</button>
      <button onClick={replace}>replace</button>
      <p>{user.name} in {user.city}</p>
    </div>
  );
}

const root = createRoot(stage);
await act(async () => root.render(<Profile />));
const [mutateButton, replaceButton] = stage.querySelectorAll('button');
const text = () => stage.querySelector('p').textContent;
const before = text();
console.log(before, renders);

await act(async () => mutateButton.click());
const afterMutate = text();
const rendersAfterMutate = renders;
console.log(afterMutate, rendersAfterMutate);

await act(async () => replaceButton.click());
console.log(text(), renders);

check('at first', before, 'Ada in London');
check('mutating changed nothing on screen', afterMutate, 'Ada in London');
check('and the component did not render again', rendersAfterMutate, 1);
check('the next real update shows the mutation too', text(), 'Grace in Paris');
check('after one render for it', renders, 2);
await act(async () => root.unmount());

What node printed (React 19.2.7, development build), when the build ran it

node profile.jsx

Ada in London 1
Ada in London 1
Grace in Paris 2
ok   at first
ok   mutating changed nothing on screen
ok   and the component did not render again
ok   the next real update shows the mutation too
ok   after one render for it

The mutation was not lost. The object in state really was changed, and React was never told, so the screen kept showing the old text until an unrelated update arrived and the render read the changed object. That is the worst kind of bug: it appears somewhere other than where it was caused. The rule is to treat state as read-only and to set a new value.

Arrays: spread, filter and map

The array methods that return a new array are the ones to use. To add, spread the old items and put the new one after them. To remove, filter. To change one, map and return a new object for the item that matches and the same object for the rest.

Adding, removing and changing items

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

todos.jsx

import { useState, act } from 'react';
import { createRoot } from 'react-dom/client';

function Todos() {
  const [items, setItems] = useState([
    { id: 1, text: 'write', done: false },
    { id: 2, text: 'test', done: false },
  ]);

  const add = () => setItems([...items, { id: items.length + 1, text: 'ship', done: false }]);
  const removeFirst = () => setItems(items.filter((item) => item.id !== 1));
  const toggleSecond = () =>
    setItems(items.map((item) => (item.id === 2 ? { ...item, done: !item.done } : item)));

  return (
    <div>
      <button onClick={add}>add</button>
      <button onClick={removeFirst}>remove</button>
      <button onClick={toggleSecond}>toggle</button>
      <ul>
        {items.map((item) => (
          <li key={item.id}>{item.done ? 'done: ' : ''}{item.text}</li>
        ))}
      </ul>
    </div>
  );
}

const root = createRoot(stage);
await act(async () => root.render(<Todos />));
const [add, remove, toggle] = stage.querySelectorAll('button');
const items = () => Array.from(stage.querySelectorAll('li')).map((li) => li.textContent);

await act(async () => add.click());
const afterAdd = items();
await act(async () => remove.click());
const afterRemove = items();
await act(async () => toggle.click());
const afterToggle = items();
console.log(afterAdd, afterRemove, afterToggle);

check('add: spread the old items', afterAdd, ['write', 'test', 'ship']);
check('remove: filter', afterRemove, ['test', 'ship']);
check('change one: map', afterToggle, ['done: test', 'ship']);
await act(async () => root.unmount());

What node printed (React 19.2.7, development build), when the build ran it

node todos.jsx

[ 'write', 'test', 'ship' ] [ 'test', 'ship' ] [ 'done: test', 'ship' ]
ok   add: spread the old items
ok   remove: filter
ok   change one: map

Sorting needs a copy

sort and reverse change the array they are called on, which is a mutation if that array is state. toSorted and toReversed return a copy and leave the original alone. Where they are not available, copy first with [...items].sort().

toSorted against sort

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

sorted.jsx

import { useState, act } from 'react';
import { createRoot } from 'react-dom/client';

const original = [3, 1, 2];
const copy = original.toSorted();
console.log(copy, original);

const inPlace = [3, 1, 2];
inPlace.sort();
console.log(inPlace);

function Sorted() {
  const [numbers] = useState([3, 1, 2]);
  return <p>{numbers.toSorted().join(',')} from {numbers.join(',')}</p>;
}

const root = createRoot(stage);
await act(async () => root.render(<Sorted />));
console.log(stage.textContent);

check('toSorted returns the sorted copy', copy, [1, 2, 3]);
check('and leaves the original', original, [3, 1, 2]);
check('sort changes the array it is called on', inPlace, [1, 2, 3]);
check('a component can show the sorted list without touching its state', stage.textContent, '1,2,3 from 3,1,2');
await act(async () => root.unmount());

What node printed (React 19.2.7, development build), when the build ran it

node sorted.jsx

[ 1, 2, 3 ] [ 3, 1, 2 ]
[ 1, 2, 3 ]
1,2,3 from 3,1,2
ok   toSorted returns the sorted copy
ok   and leaves the original
ok   sort changes the array it is called on
ok   a component can show the sorted list without touching its state

Nested objects, and copying everything

The spread is shallow: it copies one level. To change something deep you spread at every level on the path to it. The parts you did not touch are shared between the old and the new state, which is fine because nobody changes them. When you do want an independent copy of everything, the built-in structuredClone makes one.

A nested update, and structuredClone

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

nested.jsx

import { useState, act } from 'react';
import { createRoot } from 'react-dom/client';

const start = { theme: { mode: 'dark' }, user: { name: 'Ada', address: { city: 'London' } } };
let latest;

function Settings() {
  const [settings, setSettings] = useState(start);
  latest = settings;

  function move() {
    setSettings({
      ...settings,
      user: { ...settings.user, address: { ...settings.user.address, city: 'Paris' } },
    });
  }

  return <button onClick={move}>{settings.user.address.city}</button>;
}

const root = createRoot(stage);
await act(async () => root.render(<Settings />));
await act(async () => stage.querySelector('button').click());
console.log(stage.textContent);

const deep = structuredClone(start);
deep.user.address.city = 'Rome';
console.log(start.user.address.city, deep.user.address.city);

check('the screen shows the new city', stage.textContent, 'Paris');
check('the old state object still says London', start.user.address.city, 'London');
check('the new state is a different object', latest !== start, true);
check('the path that changed was copied', latest.user.address !== start.user.address, true);
check('the branch nobody touched is shared', latest.theme === start.theme, true);
check('structuredClone copies every level', deep.user !== start.user, true);
check('and changing the clone leaves the original alone', start.user.address.city, 'London');
await act(async () => root.unmount());

What node printed (React 19.2.7, development build), when the build ran it

node nested.jsx

Paris
London Rome
ok   the screen shows the new city
ok   the old state object still says London
ok   the new state is a different object
ok   the path that changed was copied
ok   the branch nobody touched is shared
ok   structuredClone copies every level
ok   and changing the clone leaves the original alone
  • Never change state in place. Set a new object or array.
  • Objects: { ...old, key: value }. Arrays: spread to add, filter to remove, map to change.
  • sort and reverse mutate; use toSorted and toReversed, or copy first.
  • Spread each level on the way down to a nested value; structuredClone copies all of them.

11 Forms: controlled and uncontrolled

A form field already remembers what is typed into it; that is what the browser does. React has to decide who is in charge. You can make React's state the truth and have the field follow it, or let the field keep the truth and ask it when you need an answer. Both are normal, and each has a mistake React will warn you about.

The panels need to type, and there is no keyboard in the DOM used to build these pages. They do what a test has to do: set the value with the browser's own setter, then fire an input event that bubbles. Setting input.value directly would not do, because React remembers the last value it wrote and would see nothing change. Each panel starts with the same short helper.

Controlled: the state is the truth

A controlled input gets value from state and reports every change through onChange, where the handler sets the state. The field never holds anything the state does not. That makes it easy to show the same text elsewhere, to change it on the way in, or to clear it.

A controlled input

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

controlled.jsx

import { useState, act } from 'react';
import { createRoot } from 'react-dom/client';

function type(input, text) {
  const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
  setter.call(input, text);
  input.dispatchEvent(new Event('input', { bubbles: true }));
}

function Hello() {
  const [name, setName] = useState('');
  return (
    <div>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <p>Hello, {name}</p>
    </div>
  );
}

function Shout() {
  const [text, setText] = useState('');
  return <input value={text} onChange={(e) => setText(e.target.value.toUpperCase())} />;
}

const root = createRoot(stage);
await act(async () => root.render(<Hello />));
const input = stage.querySelector('input');
await act(async () => type(input, 'Ada'));
console.log(input.value, stage.querySelector('p').textContent);

check('the field shows the typed text', input.value, 'Ada');
check('and the state is the same text', stage.querySelector('p').textContent, 'Hello, Ada');

await act(async () => {
  input.value = 'direct';
  input.dispatchEvent(new Event('input', { bubbles: true }));
});
console.log(stage.querySelector('p').textContent);
check('assigning .value directly is not noticed by React', stage.querySelector('p').textContent, 'Hello, Ada');

await act(async () => root.render(<Shout />));
const shout = stage.querySelector('input');
await act(async () => type(shout, 'ada'));
console.log(shout.value);
check('the state changed what the field shows', shout.value, 'ADA');
await act(async () => root.unmount());

What node printed (React 19.2.7, development build), when the build ran it

node controlled.jsx

Ada Hello, Ada
ok   the field shows the typed text
ok   and the state is the same text
Hello, Ada
ok   assigning .value directly is not noticed by React
ADA
ok   the state changed what the field shows

Uncontrolled: the field is the truth

An uncontrolled input is given a starting text with defaultValue and then left alone. You read it when it matters, usually on submit, with new FormData(form), which collects every named field of the form. For a form that is only sent and never mirrored, this is less code than a state variable per field.

An uncontrolled form, read on submit

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

uncontrolled.jsx

import { act } from 'react';
import { createRoot } from 'react-dom/client';

function type(input, text) {
  const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
  setter.call(input, text);
  input.dispatchEvent(new Event('input', { bubbles: true }));
}

let sent = null;

function Subscribe() {
  function handleSubmit(e) {
    e.preventDefault();
    sent = Object.fromEntries(new window.FormData(e.currentTarget));
  }
  return (
    <form onSubmit={handleSubmit}>
      <input name="email" defaultValue="ada@example.com" />
      <input name="topic" defaultValue="news" />
      <button>Send</button>
    </form>
  );
}

const root = createRoot(stage);
await act(async () => root.render(<Subscribe />));
await act(async () => type(stage.querySelector('input[name="email"]'), 'grace@example.com'));
await act(async () => stage.querySelector('form').requestSubmit());
console.log(sent);

check('the form data holds the typed email and the default topic', sent,
  { email: 'grace@example.com', topic: 'news' });
await act(async () => root.unmount());

What node printed (React 19.2.7, development build), when the build ran it

node uncontrolled.jsx

{ email: 'grace@example.com', topic: 'news' }
ok   the form data holds the typed email and the default topic

Checkboxes, selects and text areas

The same pattern fits the other fields, with the small differences that a checkbox is checked and reports e.target.checked, and that a <select> and a <textarea> take value as an input does, unlike in plain HTML where a textarea's text goes between its tags.

Checkbox, select and textarea

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

fields.jsx

import { useState, act } from 'react';
import { createRoot } from 'react-dom/client';

function Preferences() {
  const [agree, setAgree] = useState(false);
  const [size, setSize] = useState('small');
  const [note, setNote] = useState('');
  return (
    <div>
      <input type="checkbox" checked={agree} onChange={(e) => setAgree(e.target.checked)} />
      <select value={size} onChange={(e) => setSize(e.target.value)}>
        <option value="small">Small</option>
        <option value="large">Large</option>
      </select>
      <textarea value={note} onChange={(e) => setNote(e.target.value)} />
      <p>{String(agree)} / {size} / {note}</p>
    </div>
  );
}

const root = createRoot(stage);
await act(async () => root.render(<Preferences />));
const summary = () => stage.querySelector('p').textContent;
const before = summary();
console.log(before);

await act(async () => stage.querySelector('input').click());
const select = stage.querySelector('select');
await act(async () => {
  select.value = 'large';
  select.dispatchEvent(new Event('change', { bubbles: true }));
});
const area = stage.querySelector('textarea');
await act(async () => {
  const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set;
  setter.call(area, 'hello');
  area.dispatchEvent(new Event('input', { bubbles: true }));
});
console.log(summary());

check('before', before, 'false / small / ');
check('after a click, a choice and some typing', summary(), 'true / large / hello');
await act(async () => root.unmount());

What node printed (React 19.2.7, development build), when the build ran it

node fields.jsx

false / small / 
true / large / hello
ok   before
ok   after a click, a choice and some typing

A value with no onChange

If you give a field a value and nothing to hear changes, it can never change: React keeps putting the value back. It is a read-only field made by accident, and the development build says so. The panel types into it and then looks at what the field holds.

value without onChange

warns, on purpose · in happy-dom 20.11.6 · React dev

locked.jsx

import { act } from 'react';
import { createRoot } from 'react-dom/client';

function type(input, text) {
  const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
  setter.call(input, text);
  input.dispatchEvent(new Event('input', { bubbles: true }));
}

function Locked() {
  return <input value="fixed" />;
}

const root = createRoot(stage);
await act(async () => root.render(<Locked />));
const input = stage.querySelector('input');
await act(async () => type(input, 'typed'));
console.log(input.value);

check('the value after typing', input.value, 'fixed');
await act(async () => root.unmount());

What node printed (React 19.2.7, development build), when the build ran it

node locked.jsx

You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultValue`. Otherwise, set either `onChange` or `readOnly`.
fixed
ok   the value after typing

The fix is one of three, and the warning names them: add an onChange so it is a controlled field, use defaultValue if you only want a starting text, or add readOnly if it is meant to be fixed.

Switching from uncontrolled to controlled

A field is uncontrolled when its value is undefined and controlled when it is a string. A state variable that starts with no value and later gets one makes the field change from the first kind to the second, which React cannot do cleanly. Start with useState(''), not useState().

From undefined to a string

warns, on purpose · in happy-dom 20.11.6 · React dev

switch.jsx

import { useState, act } from 'react';
import { createRoot } from 'react-dom/client';

function type(input, text) {
  const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
  setter.call(input, text);
  input.dispatchEvent(new Event('input', { bubbles: true }));
}

function Search() {
  const [text, setText] = useState();
  return <input value={text} onChange={(e) => setText(e.target.value)} />;
}

const root = createRoot(stage);
await act(async () => root.render(<Search />));
const input = stage.querySelector('input');
await act(async () => type(input, 'abc'));
console.log(input.value);

check('the typed text still shows', input.value, 'abc');
await act(async () => root.unmount());

What node printed (React 19.2.7, development build), when the build ran it

node switch.jsx

A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component. More info: https://react.dev/link/controlled-components
abc
ok   the typed text still shows

Which to choose: controlled when other parts of the screen depend on the text as it is typed (live validation, a preview, a disabled button); uncontrolled when you only need the values at the end.

  • Controlled: value from state, onChange sets the state. The state is the truth.
  • Uncontrolled: defaultValue once, read on submit with new FormData(form).
  • Checkboxes use checked; select and textarea take value.
  • value without onChange is a read-only field. Start state with '', not undefined.

12 Lifting state up and sharing it

Sometimes two parts of a screen must agree: a filter box and the list it filters, a name in a header and the same name in a footer. If each keeps its own state they drift apart. The answer is to move the state to the nearest component that contains both, and pass it down.

The problem: two copies

Here the same Counter appears twice. Each calls useState, so each owns a number, and a click on one tells the other nothing.

Each copy has its own state

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

drift.jsx

import { useState, act } from 'react';
import { createRoot } from 'react-dom/client';

function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

const root = createRoot(stage);
await act(async () => root.render(<><Counter /><Counter /></>));
const buttons = stage.querySelectorAll('button');
await act(async () => buttons[0].click());
const shown = Array.from(buttons).map((b) => b.textContent);
console.log(shown);

check('the copies disagree', shown, ['1', '0']);
await act(async () => root.unmount());

What node printed (React 19.2.7, development build), when the build ran it

node drift.jsx

[ '1', '0' ]
ok   the copies disagree

The fix: the parent owns it

Move the state up. The parent calls useState once and passes the value to each child as a prop. Whatever changes it, here a button that has no state of its own, is given a function: onChange is a prop like any other, and calling it runs the setter in the parent. Data flows down as props and requests to change it flow up as function calls.

The full name is not stored. It is two pieces of state glued together while rendering, so there is nothing to keep in step: change either part and every place that shows the full name is right on the next render.

State in the parent, a value computed from it

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

lifted.jsx

import { useState, act } from 'react';
import { createRoot } from 'react-dom/client';

let renders = 0;

function Heading({ name }) {
  return <h1>{name}</h1>;
}

function Footer({ name }) {
  return <p>Signed by {name}</p>;
}

function Picker({ onPick }) {
  return <button onClick={() => onPick('Grace')}>Use Grace</button>;
}

function Page() {
  const [first, setFirst] = useState('Ada');
  const [last] = useState('Lovelace');
  const fullName = first + ' ' + last;
  renders += 1;
  return (
    <div>
      <Heading name={fullName} />
      <Picker onPick={setFirst} />
      <Footer name={fullName} />
    </div>
  );
}

const root = createRoot(stage);
await act(async () => root.render(<Page />));
const heading = () => stage.querySelector('h1').textContent;
const footer = () => stage.querySelector('p').textContent;
const before = [heading(), footer()];
const rendersBefore = renders;
console.log(before);

await act(async () => stage.querySelector('button').click());
const after = [heading(), footer()];
console.log(after, renders);

check('both show the starting name', before, ['Ada Lovelace', 'Signed by Ada Lovelace']);
check('one click changed both', after, ['Grace Lovelace', 'Signed by Grace Lovelace']);
check('the derived name followed the first name, with no state of its own', heading(), 'Grace Lovelace');
check('mounting took one render', rendersBefore, 1);
check('the click took one more', renders, 2);
await act(async () => root.unmount());

What node printed (React 19.2.7, development build), when the build ran it

node lifted.jsx

[ 'Ada Lovelace', 'Signed by Ada Lovelace' ]
[ 'Grace Lovelace', 'Signed by Grace Lovelace' ] 2
ok   both show the starting name
ok   one click changed both
ok   the derived name followed the first name, with no state of its own
ok   mounting took one render
ok   the click took one more

Notice what the siblings do not know about each other. Heading and Footer take a name and show it; neither knows where it came from or that the other exists. That is what makes components reusable.

Two questions for any piece of state. Who needs it? Put it in the nearest parent of all of them. Can it be worked out from state or props you already have? Then compute it while rendering and do not store it. A stored copy can be wrong; a computed value cannot.

  • Two components that must agree share state kept in their nearest common parent.
  • The parent passes the value down as a prop, and a function that changes it as another prop.
  • Do not store what you can compute: derive it during the render.

Newsletter

New writing, when it lands.

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