Skip to content

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 declares a piece of state. It returns a pair: the current value, and a function that replaces it and schedules a re-render.

Counter.jsx
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).

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 gone
setUser({ ...user, age: 37 }); // right — copy, then override

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 render
const [data, setData] = useState(readFromLocalStorage());
// runs only on the first render
const [data, setData] = useState(() => readFromLocalStorage());

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.

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-render
setUser({ ...user }); // new object identity → re-render, even if contents match

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 render

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>

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: a stopPropagation() from a non-React listener above the root can block React handlers, and document-level listeners still fire after React’s.
  • No pooling. Event pooling (where e was 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.

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 changes

Three details that explain most surprises:

  1. Re-render ≠ DOM update. Your function running again is cheap. Only differences reach the DOM.
  2. 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.
  3. Updates are batched. Multiple setState calls 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.

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.

NameField.jsx
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())}
// 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)} />
SignupForm.jsx
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.

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.

// Wrong
user.name = 'Grace'; setUser(user);
todos.push(newTodo); setTodos(todos);
items.sort(); setItems(items);
// Right — produce a new value
setUser({ ...user, name: 'Grace' });
setTodos([...todos, newTodo]);
setItems([...items].sort());
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)).

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.

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.

Temperature.jsx
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.

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 drift
const [items, setItems] = useState([]);
const [total, setTotal] = useState(0);
// every setItems must remember to setTotal...
// Right: one source of truth
const [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.

  • useState returns [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 (checked for checkboxes, value on <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.