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.
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.
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.
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.
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;Capturehandlers run first. - A form's
onSubmitstarts withe.preventDefault(). actwaits 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.
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.
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.
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.
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.
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.
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().
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.
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,filterto remove,mapto change. sortandreversemutate; usetoSortedandtoReversed, or copy first.- Spread each level on the way down to a nested value;
structuredClonecopies 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.
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.
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.
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.
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().
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:
valuefrom state,onChangesets the state. The state is the truth. - Uncontrolled:
defaultValueonce, read on submit withnew FormData(form). - Checkboxes use
checked; select and textarea takevalue. valuewithoutonChangeis a read-only field. Start state with'', notundefined.
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.
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.
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.