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. Note: The browser demos here load React 19.3.0 from an ESM CDN, because React 19 stopped publishing UMD builds and the official replacement is this kind of import from esm.sh. Real projects use Vite (Section 2.1.4) and never ship the in-browser Babel transform.

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>

Listing 2.14: A counter, clicked three times before the screenshot 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. Under the hood: state lives on the fiber node React keeps for each mounted component, in a linked list of hook records, not in the function's scope. That is why a component with unchanged props and state can be skipped, and why unmounting one throws its state away for good.

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>

);

}

Listing 2.15: A local variable, a module variable and state, side by side Drop it into the page from Section 2.3.1 and click three times: the paragraph reads local 0 | outside 3 | state 3 | handler saw 2. local is 0 because each handler incremented the variable belonging to the render on screen at the time, and the render that replaced it declared a new local = 0. outside reached 3, but is visible only because a state change forced a render that read it. The last field matters most: the handler on the third click saw 2, the value baked into that render's closure, even though it had just asked for 3.That is the rule worth memorizing: within one render, every state value is a constant. setCount does not reach back and rewrite the count your handler closed over, so console.log(count) on the next line prints the old number. Compute the next value into a local variable if you need it in the same handler. Did you know? This is why React calls a render a snapshot. Props, state and the handlers built from them belong to one render and never change afterwards, which is what makes it safe for React to prepare a render in the background, throw it away, and start again (Section 2.8.1).

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

}

Listing 2.16: Two updates in one handler: replacement versus updater 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. Tip: Name updater parameters after the state (c, todos, draft), never count. Shadowing the outer name is what stops you reaching for the stale snapshot by accident.

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' }

}));

Listing 2.17: Replacing instead of mutating The spread is shallow: { ...form } copies address by reference, so changing form.address.city in place would still mutate shared data. Arrays follow the same rule, and the methods that read most naturally are exactly the ones that mutate. Operation Avoid Replacement Append push, unshift [...items, next] Remove splice, pop items.filter(i => i.id !== id) Change one items[i] = next items.with(i, next) Insert splice items.toSpliced(i, 0, next) Reorder sort, reverse toSorted, toReversed Table 2.6: Array updates that hand React a new array instead of the old one with, toSorted, toReversed and toSpliced are the ES2023 copying methods, in every current browser and in Node since version 20. Where the shape is deep enough that spreads become unreadable, reach for Immer (immerjs/immer (https://github.com/immerjs/immer), npm i immer use-immer): its produce hands you a proxy you may mutate freely and returns a new object built by structural sharing, so untouched branches keep their identity and memoization keeps working. Warning: Mutating state is worse than a missing render. Concurrent rendering (Section 2.8) can render a component twice at different priorities, and a mutated object makes those passes disagree.

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 ?? '{}'));

Listing 2.18: An initializer that runs once, and one that runs on every render The difference is one pair of parentheses, which is why it is so easy to get wrong. A quick test: if the expression would be worth wrapping in useMemo, it belongs behind an initializer instead.An initializer must be pure and takes no arguments. In development, Strict Mode (Section 2.11.8) calls it twice on purpose, so one that increments a counter or pushes onto a shared array shows up as a bug on your machine rather than in production. Updater functions get the same treatment. Note: Lazy initialization runs only when the component mounts. Changing a prop the initializer read will not re-run it — the state was set once and now belongs to the component. When you want that, lift the state up or reset the subtree with a key (Section 2.3.7).

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 });

}

Listing 2.19: One state object versus state grouped by what changes together Forgetting the spread in the first version silently deletes width and height, because useState never merges the way this.setState did. The second cannot lose anything, and it splits cleanly: the position logic moves into a custom hook (Section 2.10.1) and BetterBox keeps only what it owns.Keep values together when they genuinely travel together — the two ends of a range slider, the x and y of a point, a form's fields when you submit them as one object. Split them when one changes sixty times a second and the other twice an hour. The updater is the signal: if most of it is spread syntax carrying values you never touch, those values want their own useState. Tip: Once a component holds four or five related state variables whose updates always fire together, the shape you want is a reducer (Section 2.5.6), not more useState calls.

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 */}

</>

);

}

Listing 2.20: State lifted to the parent, and a subtree reset with a key ContactList no longer remembers which row is highlighted; it renders selected and reports clicks through onSelect. That is the whole pattern, and it is why so much React code ends up as a stateful container with stateless leaves.The key on <Chat> solves a different problem. React identifies a component by its position in the tree, so switching contacts would reuse the same Chat and keep a half-typed message meant for someone else. A changing key says the two are not the same component: the old unmounts, its state and effects go, and a fresh one mounts with text back to ''.Figure 2.11: The same two children before and after the shared value is lifted into the parent Tip: A key is the cheapest state reset there is — no effect, no useEffect that watches a prop and clears state. Anywhere you would have written "when the id changes, clear the form", pass key={id}.

2.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>

Listing 2.21: Capture on the way down, target, then bubbling on the way up 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. Warning: onClick={() => setOpen(true)} creates a new function on every render. That is fine; the allocation is not what makes apps slow. It starts to matter only when the child is wrapped in memo (Section 2.7.4).

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 Family Handlers What it adds Mouse onClick, onMouseEnter clientX, button, shiftKey Keyboard onKeyDown, onKeyUp key, code, repeat Focus onFocus, onBlur relatedTarget Form onChange, onSubmit target.value Pointer onPointerDown, onPointerUp pointerId, pressure Table 2.7: Synthetic event families and the properties each one adds to the base event target is the element the event started on; currentTarget is the one whose handler is running. In focus events relatedTarget is the element losing focus, which is how you tell "focus moved between two children of this panel" from "focus left the panel": test e.currentTarget.contains(e.relatedTarget). History: Until React 17, synthetic events were pooled — the object was reused and blanked after the handler returned, so reading e.target inside a setTimeout gave null unless you called e.persist(). Pooling is gone, persist() is a no-op kept for compatibility, and event objects are ordinary values.