State and Events
Props are data a component receives. State is data a component owns and can change. Changing state is the only way to make React update the screen.
useState
Section titled “useState”useState declares a piece of state. It returns a pair: the current value, and a function that replaces it and schedules a re-render.
import { useState } from 'react';
function Counter() { const [count, setCount] = useState(0);
return ( <button onClick={() => setCount(count + 1)}> Clicked {count} times </button> );}The array destructuring is convention, not magic — useState returns a two-element array and you name both halves. The universal naming is [thing, setThing].
Call useState once per independent value. Multiple state variables in one component are normal and usually clearer than one big object:
const [firstName, setFirstName] = useState('');const [lastName, setLastName] = useState('');const [isOpen, setIsOpen] = useState(false);Group values into an object only when they genuinely change together (like a form’s fields, or { x, y } coordinates).
The setter replaces; it does not merge
Section titled “The setter replaces; it does not merge”Unlike class components’ this.setState, useState’s setter overwrites the value entirely.
const [user, setUser] = useState({ name: 'Ada', age: 36 });
setUser({ age: 37 }); // wrong — name is now gonesetUser({ ...user, age: 37 }); // right — copy, then overrideLazy initial state
Section titled “Lazy initial state”The argument to useState is only used on the first render, but the expression still runs on every render. If it is expensive, pass a function instead — React calls it once.
// readFromLocalStorage() runs on every single renderconst [data, setData] = useState(readFromLocalStorage());
// runs only on the first renderconst [data, setData] = useState(() => readFromLocalStorage());Functional updates
Section titled “Functional updates”When the next value depends on the previous one, pass a function. React calls it with the latest queued value rather than the one captured by the current render.
setCount((c) => c + 1);This matters because state is a snapshot. Within one render, count is a constant. Three calls in a row with the direct form all read the same count:
function handleClick() { setCount(count + 1); // count is 0 → queues "set to 1" setCount(count + 1); // count is still 0 → queues "set to 1" setCount(count + 1); // count is still 0 → queues "set to 1" // final value: 1}With updater functions each one receives the result of the previous:
function handleClick() { setCount((c) => c + 1); // 0 → 1 setCount((c) => c + 1); // 1 → 2 setCount((c) => c + 1); // 2 → 3 // final value: 3}Setting state does not change the current variable
Section titled “Setting state does not change the current variable”function handleClick() { setCount(count + 1); console.log(count); // still the old value — this render's snapshot}The new value only exists in the next render. Nothing you do can make count change mid-render, and that is deliberate: it means every render’s output is a consistent picture of one state.
Bailing out
Section titled “Bailing out”If you set state to a value that is Object.is-equal to the current one, React skips the re-render.
setCount(0); // already 0 → no re-rendersetUser({ ...user }); // new object identity → re-render, even if contents matchEvents
Section titled “Events”React attaches event handlers as props: onClick, onChange, onSubmit, onKeyDown, onFocus, onBlur, onMouseEnter, and so on — camelCase versions of the DOM event names.
<button onClick={handleClick}>Save</button>Pass the function, not a call to it:
<button onClick={handleClick}>OK</button> // right<button onClick={handleClick()}>OK</button> // wrong: calls it during renderPassing arguments
Section titled “Passing arguments”Wrap it in an arrow function so the call happens on click, not on render.
<button onClick={() => deleteItem(item.id)}>Delete</button>If you also need the event:
<button onClick={(e) => { e.preventDefault(); deleteItem(item.id); }}>Delete</button>The synthetic event
Section titled “The synthetic event”Handlers receive a SyntheticEvent: a cross-browser wrapper with the same interface as the native event (target, currentTarget, preventDefault(), stopPropagation(), key, clientX, …). The real DOM event is available as e.nativeEvent.
function handleSubmit(e) { e.preventDefault(); // stop the browser navigating away console.log(e.target); // the <form> element}
<form onSubmit={handleSubmit}>…</form>Two mechanics worth knowing:
- Delegation. React does not attach a listener to each DOM node. Since React 17 it attaches one listener per event type on the root container you passed to
createRoot, and dispatches from there. Consequences: astopPropagation()from a non-React listener above the root can block React handlers, anddocument-level listeners still fire after React’s. - No pooling. Event pooling (where
ewas recycled and nulled after the handler) was removed in React 17. You can safely use the event object asynchronously.
To listen on the capture phase, append Capture: onClickCapture, onFocusCapture.
The re-render mental model
Section titled “The re-render mental model”event → setState(next) → React marks the component dirty → React re-runs that component's function (and its children's) → new element tree → diff against previous → commit minimal DOM changesThree details that explain most surprises:
- Re-render ≠ DOM update. Your function running again is cheap. Only differences reach the DOM.
- Re-rendering a component re-renders its children by default, whether or not their props changed. That is usually fine; see performance for when it is not.
- Updates are batched. Multiple
setStatecalls in the same tick produce one re-render. Since React 18 this batching applies everywhere — event handlers, promises,setTimeout, native handlers.
function handleClick() { setA(1); setB(2); setC(3); // one re-render, not three}If you need the DOM updated synchronously before the next line (rare — measuring layout, integrating with a non-React library), flushSync from react-dom forces it. Reach for it last.
Controlled components and forms
Section titled “Controlled components and forms”A controlled input takes its value from state and reports changes back through a handler. React state is the single source of truth; the DOM just displays it.
function NameField() { const [name, setName] = useState('');
return ( <> <input value={name} onChange={(e) => setName(e.target.value)} /> <p>Hello, {name || 'stranger'}</p> </> );}Because the value flows through state, you can transform, validate, or restrict it in the handler:
onChange={(e) => setName(e.target.value.slice(0, 20).toUpperCase())}Each input type
Section titled “Each input type”// text / email / number / password — value + onChange<input type="text" value={text} onChange={(e) => setText(e.target.value)} />
// textarea uses value, not children<textarea value={bio} onChange={(e) => setBio(e.target.value)} />
// select uses value on the <select>, not `selected` on an <option><select value={country} onChange={(e) => setCountry(e.target.value)}> <option value="fr">France</option> <option value="jp">Japan</option></select>
// multi-select: value is an array<select multiple value={langs} onChange={(e) => setLangs(Array.from(e.target.selectedOptions, (o) => o.value))}> …</select>
// checkbox uses checked, and reads e.target.checked<input type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} />
// radio group: same name, checked compares to the state value<input type="radio" name="plan" value="pro" checked={plan === 'pro'} onChange={(e) => setPlan(e.target.value)} />Many fields, one state object
Section titled “Many fields, one state object”function SignupForm() { const [form, setForm] = useState({ email: '', password: '', newsletter: false });
function handleChange(e) { const { name, value, type, checked } = e.target; setForm((prev) => ({ ...prev, [name]: type === 'checkbox' ? checked : value })); }
function handleSubmit(e) { e.preventDefault(); console.log(form); }
return ( <form onSubmit={handleSubmit}> <input name="email" type="email" value={form.email} onChange={handleChange} /> <input name="password" type="password" value={form.password} onChange={handleChange} /> <label> <input name="newsletter" type="checkbox" checked={form.newsletter} onChange={handleChange} /> Subscribe </label> <button type="submit">Sign up</button> </form> );}The computed key [name]: ... is what makes one handler serve every field.
Put the submit logic on the <form>’s onSubmit, not the button’s onClick — that way Enter in a text field submits too, which is what users expect.
Immutability
Section titled “Immutability”Never mutate state. React compares the old and new value by identity (Object.is). Mutating an object or array keeps the same identity, so React sees no change and skips the re-render — or worse, re-renders later and shows a value that changed silently in between.
// Wronguser.name = 'Grace'; setUser(user);todos.push(newTodo); setTodos(todos);items.sort(); setItems(items);
// Right — produce a new valuesetUser({ ...user, name: 'Grace' });setTodos([...todos, newTodo]);setItems([...items].sort());Array recipes
Section titled “Array recipes”| Operation | Do this |
|---|---|
| Add to end | setItems([...items, item]) |
| Add to start | setItems([item, ...items]) |
Insert at index i |
setItems([...items.slice(0, i), item, ...items.slice(i)]) |
| Remove by id | setItems(items.filter((x) => x.id !== id)) |
| Replace by id | setItems(items.map((x) => (x.id === id ? { ...x, done: true } : x))) |
| Sort / reverse | setItems([...items].sort(cmp)) — sort and reverse mutate |
| Clear | setItems([]) |
toSorted(), toReversed(), with(), and toSpliced() (ES2023, available in current browsers and Node 20+) return new arrays and are a tidier alternative: setItems(items.toSorted(cmp)).
Nested objects
Section titled “Nested objects”Every level you change must be copied. Copying only the top level leaves the inner object shared with the previous state.
setUser({ ...user, address: { ...user.address, city: 'Paris' },});When nesting gets deep, that is a signal: flatten your state shape, split it into more useState calls, or use a library like Immer (useImmer) that lets you write mutating-looking code and produces an immutable copy.
Lifting state up
Section titled “Lifting state up”When two components need the same data, move the state to their closest common parent and pass it down — value as a prop, updates as a callback prop.
Before: two independent counters that cannot agree on anything.
function Panel() { const [count, setCount] = useState(0); // each Panel has its own …}After: the parent owns the state, the children become controlled.
function Temperature() { const [celsius, setCelsius] = useState(20);
return ( <> <NumberInput label="°C" value={celsius} onChange={setCelsius} /> <NumberInput label="°F" value={celsius * 9 / 5 + 32} onChange={(f) => setCelsius((f - 32) * 5 / 9)} /> </> );}
function NumberInput({ label, value, onChange }) { return ( <label> {label} <input type="number" value={value} onChange={(e) => onChange(Number(e.target.value))} /> </label> );}NumberInput now holds no state at all. It renders what it is given and reports what the user did — the same controlled pattern as a DOM <input>, one level up.
Lift only as far as necessary. State that lives higher than it needs to causes re-renders in every component in between. When passing props through many layers gets painful, that is the case for context or an external store, not for lifting further.
Derived state: compute, don’t store
Section titled “Derived state: compute, don’t store”If a value can be calculated from existing props or state, calculate it during render. Do not put it in its own state variable and try to keep it in sync.
// Wrong: two sources of truth that will driftconst [items, setItems] = useState([]);const [total, setTotal] = useState(0);// every setItems must remember to setTotal...
// Right: one source of truthconst [items, setItems] = useState([]);const total = items.reduce((sum, i) => sum + i.price, 0);The same applies to filtered lists, counts, validity flags, and formatted strings:
const [query, setQuery] = useState('');const [users, setUsers] = useState([]);
const visible = users.filter((u) => u.name.toLowerCase().includes(query.toLowerCase()));const isValid = query.length >= 2;Recomputing on every render is almost always cheaper than the bugs caused by duplicated state. If profiling ever shows a derivation is genuinely expensive, wrap it in useMemo — but measure first.
A useful test: can this value be wrong? If it can disagree with the data it came from, it should not be state.
Key points
Section titled “Key points”useStatereturns[value, setter]; the setter replaces the value and schedules a re-render.- State is a per-render snapshot — it never changes mid-render. Use
setX((prev) => …)when the next value depends on the previous. - Pass an initializer function for expensive initial state.
- Handlers receive a
SyntheticEvent; React delegates from the root container and no longer pools events. - Updates are batched everywhere since React 18; one re-render per tick.
- Controlled inputs:
value+onChange(checkedfor checkboxes,valueon<select>). - Never mutate state — build a new object or array; copy every level you change.
- Lift state to the closest common parent; keep it as low as possible.
- Derive values during render instead of storing them in state.