MENU
State and Events
A component function runs from top to bottom every time React renders it, and every local variable it declares disappears when it returns. State is the memory that survives: a value React keeps outside the function, hands back on the next call, and watches for changes. Events are what change it. Together they are the whole of React's interactivity model â no two-way binding, no observables, no digest cycle.The loop always runs in the same order. React calls your component, gets a description of the UI back, and commits the difference to the DOM. The browser delivers an event, your handler calls a setter, React schedules another call to the same component, and the loop repeats with the new value.
Figure 2.8: One turn of the state loop: render, commit, event, queued
update, render againEverything here is built on useState. State that many components
share moves to context in Section 2.5, and state with complicated
transitions moves to useReducer in Section 2.5.6, but both refine the
same loop. Getting the loop right â when a value is stale, when a
re-render happens, what an event object actually is â keeps the rest of
React predictable.2.3.1 Creating and Updating State
useState returns a pair: the current value and a setter. The argument is the value React uses the first time the component renders and ignores afterwards. Array destructuring lets you name both halves whatever suits the component; the convention is x / setX.<style>body{font:15px/1.7 system-ui;margin:14px}button{font:inherit;padding:3px 9px}</style>
<div id="root"></div>
<script type="importmap">{"imports":{"react":"https://esm.sh/react@19.3.0",
"react-dom/client":"https://esm.sh/react-dom@19.3.0/client?external=react"}}</script>
<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@7/babel.min.js"></script>
<script type="text/babel" data-type="module">
import React, { useState } from 'react';
import { createRoot } from 'react-dom/client';
function Counter() {
const [count, setCount] = useState(0); // 0 is the value for the first render only
return (
<p>You clicked {count} times{' '}
<button onClick={() => setCount(count + 1)}>Click me</button></p>
);
}
createRoot(document.getElementById('root')).render(<Counter />);
[300, 400, 500].forEach(t => setTimeout(() => document.querySelector('button').click(), t));
</script>
Figure 2.9: Browser output of Listing 2.14The setter assigns nothing. It records that this component has a new
value for that state slot and asks React to call the component again;
the <p> above is the return value of the fourth call. A component
may hold as many slots as it likes, and React tells them apart by the
order the useState calls run in â which is why a hook may never sit
inside a condition or a loop (Section 2.10.2).One behavior surprises people arriving from class components: if the
value you pass is the one already stored, judged by Object.is, React
skips the re-render entirely, where this.setState re-rendered
anyway.2.3.2 Why State Is Not a Variable
A variable declared inside a component is created fresh on every call and thrown away when the function returns, so incrementing it changes nothing you can see. One declared outside the component survives, but React has no idea it changed, so the screen keeps the old markup until something else forces a render. State is the only value that both survives and schedules the render.let outside = 0; // module scope: survives, but never triggers a render
function Demo() {
let local = 0; // created again on every render
const [count, setCount] = useState(0);
const [seen, setSeen] = useState('-');
function handleClick() {
local++; outside++;
setCount(count + 1);
setSeen(String(count)); // the snapshot this render was handed
}
return (
<p>local {local} | outside {outside} | state {count} | handler saw {seen}{' '}
<button onClick={handleClick}>Click</button></p>
);
}
2.3.3 Functional Updates
Because count is frozen for the life of a render, calling setCount(count + 1) twice in one handler asks twice for the same result. Pass a function instead and React calls it with the value produced by everything already queued, so the increments compose.const [count, setCount] = useState(0);
function twiceByValue() { // both calls queue "replace with 0 + 1"
setCount(count + 1);
setCount(count + 1); // count is still 0 here, so this ends at 1
}
function twiceByUpdater() { // each updater receives the pending value
setCount(c => c + 1);
setCount(c => c + 1); // 0 -> 1 -> 2
}
function mixed() {
setCount(count + 1); // queue: replace with 1
setCount(c => c + 1); // queue: 1 -> 2
setCount(count + 1); // queue: replace with 1 again, so it ends at 1
}
Figure 2.10: The queue React builds for one handler, and the value it
ends onSince React 18 the queue is flushed once per event and also once per
promise callback, timeout and native handler â automatic batching
applies everywhere, not only inside React's own handlers. Wherever two
updates to the same state can land in one batch, the updater form is the
safe one.Updaters have a second use: they let a child change a parent's state
without knowing the current value. <Stepper onBump={setCount} />
gives the child a setter it calls as onBump(n => n + 1);
onBump(count+1) would fail, because count is not in the child's scope at
all.2.3.4 Updating Objects and Arrays
React decides whether to re-render by comparing the new state with the old using Object.is. Mutate an object and hand back the same reference and the comparison says "unchanged", so nothing happens on screen although the data is different. Every update has to produce a new object.const [form, setForm] = useState({ name: 'Ada', address: { city: 'London' } });
form.name = 'Grace'; // mutation: still the same object...
setForm(form); // ...so React bails out and nothing renders
setForm({ ...form, name: 'Grace' }); // a shallow copy is enough for one level
setForm(f => ({ // copy every level you actually change
...f, address: { ...f.address, city: 'Cambridge' }
}));
2.3.5 Lazy State Initialization
The argument to useState is an ordinary expression, evaluated on every render even though React uses it only on the first. For a number that costs nothing. For anything that reads localStorage, parses JSON or builds a large array, you pay the full price on every keystroke and throw the result away.// createInitialTodos() runs on every render; React keeps only the first result
const [slow, setSlow] = useState(createInitialTodos());
// the function itself is passed, so React calls it on the first render only
const [todos, setTodos] = useState(createInitialTodos);
// the usual shape when the initial value needs arguments or a fallback
const [draft, setDraft] = useState(() => JSON.parse(localStorage.draft ?? '{}'));
2.3.6 Separating State Variables
Class components had one this.state object, so everything went into it. Hooks let you call useState as often as you like, and the useful rule is to group values that change together and split values that do not.function Box() {
// one blob: every mouse move has to re-spread width and height to keep them
const [state, setState] = useState({ left: 0, top: 0, width: 100, height: 100 });
useEffect(() => {
const onMove = e => setState(s => ({ ...s, left: e.pageX, top: e.pageY }));
window.addEventListener('mousemove', onMove);
return () => window.removeEventListener('mousemove', onMove);
}, []);
}
function BetterBox() {
const position = useWindowPosition(); // a custom hook, Section 2.10.1
const [size, setSize] = useState({ width: 100, height: 100 });
}
2.3.7 Lifting State Up and Resetting with Keys
When two components need the same value, neither can own it. Move the state to their closest common parent and pass it down: the value as one prop, the setter as another. The children become controlled by the parent, and there is exactly one copy of the truth.function Chat({ contact }) {
const [text, setText] = useState('');
return <textarea value={text} onChange={e => setText(e.target.value)} />;
}
function Messenger({ contacts }) {
const [to, setTo] = useState(contacts[0]); // the shared value lives here
return (
<>
<ContactList contacts={contacts} selected={to} onSelect={setTo} />
<Chat key={to.id} contact={to} /> {/* new key: new Chat, empty draft */}
</>
);
}
Figure 2.11: The same two children before and after the shared value
is lifted into the parent2.3.8 Handling Events
An event handler is a prop whose name starts with on and whose value is a function â passed, never called. onClick={handleClick} hands React the function; onClick={handleClick()} calls it during rendering and gives React whatever it returned. Handlers also see events from the elements inside them, because React replays the same three phases the DOM uses.<style>body{font:15px/1.6 system-ui;margin:14px}button{font:inherit;padding:3px 9px}</style>
<div id="root"></div>
<script type="importmap">{"imports":{"react":"https://esm.sh/react@19.3.0",
"react-dom/client":"https://esm.sh/react-dom@19.3.0/client?external=react"}}</script>
<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@7/babel.min.js"></script>
<script type="text/babel" data-type="module">
import React, { useState } from 'react';
import { createRoot } from 'react-dom/client';
function Panel() {
const [log, setLog] = useState([]);
const add = m => setLog(l => [...l, m]);
return (
<div onClickCapture={() => add('capture: outer div')}
onClick={() => add('bubble: outer div')}>
<button onClick={e => add(`target: ${e.target.tagName}`)}>Click me</button>
<ol>{log.map((m, i) => <li key={i}>{m}</li>)}</ol>
</div>);
}
createRoot(document.getElementById('root')).render(<Panel />);
setTimeout(() => document.querySelector('button').click(), 400);
</script>
Figure 2.12: Browser output of Listing 2.21Append Capture to any handler name to run it on the way down.
e.stopPropagation() ends the journey, so a click inside a modal never
reaches the backdrop handler that closes it; e.preventDefault() cancels
the browser's own behavior for the few events that have one â form
submission, link navigation, the context menu. The two are independent,
and confusing them is why so many forms reload the page.Two departures from the DOM are worth remembering. React's onChange
fires on every keystroke, like the native input event, not when the
field loses focus. And onScroll does not bubble.2.3.9 The Synthetic Event System
The object your handler receives is not the browser's event. It is a SyntheticEvent, a wrapper with the same interface â type, target, currentTarget, preventDefault(), stopPropagation() â that behaves identically in every browser. The original is one property away, as e.nativeEvent, and the two do not map one to one: inside onMouseLeave, e.nativeEvent is a mouseout event.React does not attach a listener to your <button>. It attaches one listener per event type to the root container you passed to createRoot; when an event arrives there, React walks the fiber tree to find which components were involved and calls their handlers in capture-then-bubble order.
Figure 2.13: One native listener on the root container drives every
handler in the tree