Skip to main content
Published / updated

State and Events

Before you start

You need: React fundamentals (Article 01).

Time: about 45 minutes, plus the practice.

Learning objective

Manage component state correctly, including the batching and immutability rules that cause the most common React bugs.

Topics

  • useState
  • Why updates are asynchronous
  • The functional updater
  • Immutable updates
  • Event handling
  • Lifting state up
  • Derived state
  • useReducer
  • Diagnosing state bugs

useState

import { useState } from 'react';

export function StudentSearch() {
const [searchTerm, setSearchTerm] = useState('');
const [page, setPage] = useState(1);
const [students, setStudents] = useState<Student[]>([]);
const [selected, setSelected] = useState<Student | null>(null);

return (
<input
type="search"
value={searchTerm}
onChange={event => setSearchTerm(event.target.value)}
/>
);
}

useState returns the current value and a setter. Calling the setter tells React to re-render with the new value.

Type the state when TypeScript cannot infer it. useState([]) infers never[], so pushing a Student is a compile error. useState<Student[]>([]) is correct.

An expensive initial value should be a function, so it runs once rather than on every render:

const [filters, setFilters] = useState(() => readFiltersFromStorage());

Without the arrow, readFiltersFromStorage() runs on every render and its result is discarded.

Updates are asynchronous

function handleClick() {
setCount(count + 1);
console.log(count); // still the OLD value
}

State is not a variable you mutate — it is a value React replaces on the next render. count in this function is captured from the render that created it and never changes.

function handleClick() {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// count increases by ONE, not three
}

All three read the same stale count. React batches them and the last write wins.

The functional updater

function handleClick() {
setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1);
// count increases by three
}

The updater receives the latest value, so each call builds on the previous.

Use the functional form whenever the new value depends on the old one. That covers counters, toggles, and any list operation:

setStudents(prev => [...prev, newStudent]);
setSelected(prev => prev === null ? student : null);
setPage(prev => prev + 1);

It also removes the need for the current value in a dependency array, which the hooks article covers.

Immutable updates

React compares by reference. Mutating an object or array in place changes nothing React can see, and the component does not re-render.

// Wrong — same array reference, no re-render
students.push(newStudent);
setStudents(students);

// Right — new reference
setStudents(prev => [...prev, newStudent]);
// Add
setStudents(prev => [...prev, newStudent]);

// Remove
setStudents(prev => prev.filter(s => s.publicId !== publicId));

// Update one item
setStudents(prev => prev.map(s =>
s.publicId === publicId ? { ...s, className: '11th' } : s));

// Insert at a position
setStudents(prev => [...prev.slice(0, index), newStudent, ...prev.slice(index)]);

// Sort without mutating
setStudents(prev => [...prev].sort((a, b) => a.name.localeCompare(b.name)));

sort and reverse mutate. students.sort(...) changes the state array in place and returns the same reference, so nothing re-renders — and the original order is destroyed. Copy first, or use toSorted().

Objects, including nested ones:

setStudent(prev => ({ ...prev, name: 'Ravi K' }));

setStudent(prev => ({
...prev,
address: { ...prev.address, city: 'Bengaluru' }
}));

Spread copies one level. A nested object is shared, so mutating it through the copy changes the original — and React sees no change at the top level either.

Deeply nested state is a signal to flatten it or use a reducer.

Event handling

<button type="button" onClick={handleClick}>Save</button>
<button type="button" onClick={() => handleDelete(student.publicId)}>Delete</button>
<input onChange={event => setSearchTerm(event.target.value)} />
<form onSubmit={handleSubmit}></form>

Pass the function, do not call it:

onClick={handleClick} // correct — a reference
onClick={handleClick()} // wrong — calls it during render

The second runs on every render, which for a delete handler means deleting on render. An arrow wrapper is how you pass arguments.

function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
save();
}

function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
setSearchTerm(event.target.value);
}

function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key === 'Enter') {
search();
}
}

event.preventDefault() on a form submit is mandatory — without it the browser reloads the page and the SPA restarts.

React events are synthetic: a cross-browser wrapper with the same API as the DOM event. event.target.value works as expected.

Controlled inputs

const [name, setName] = useState('');

<input value={name} onChange={e => setName(e.target.value)} />

The state is the source of truth. Without onChange, the input is read-only and React warns:

You provided a value prop to a form field without an onChange handler.

value={undefined} makes the input uncontrolled, and switching to a defined value later produces:

A component is changing an uncontrolled input to be controlled.

The fix is to initialise state to '', never undefined. That warning appears constantly when loading a record into a form.

Lifting state up

Two components needing the same state means it belongs in their nearest common parent.

export function StudentScreen() {
const [selected, setSelected] = useState<Student | null>(null);
const [students, setStudents] = useState<Student[]>([]);

return (
<div className="split">
<StudentList
students={students}
selectedId={selected?.publicId ?? null}
onSelect={setSelected}
/>

<StudentDetail
student={selected}
onSaved={updated => {
setStudents(prev => prev.map(s =>
s.publicId === updated.publicId ? updated : s));
setSelected(updated);
}}
/>
</div>
);
}

State lives in the parent; children receive values and callbacks. This is the default answer to "how do these components share data" — before reaching for context or a state library.

When props are being passed through four levels that do not use them, that is prop drilling, and context is the answer. That is covered in the state-management article.

Derived state

Do not store what you can calculate.

// Wrong — two sources of truth that will disagree
const [students, setStudents] = useState<Student[]>([]);
const [filtered, setFiltered] = useState<Student[]>([]);
const [count, setCount] = useState(0);

Every place that sets students must remember to set the other two. One that forgets produces a count that does not match the list.

// Right — one source, the rest derived during render
const [students, setStudents] = useState<Student[]>([]);
const [searchTerm, setSearchTerm] = useState('');

const filtered = students.filter(s =>
s.name.toLowerCase().includes(searchTerm.toLowerCase()));

const count = filtered.length;

Derived values recalculate on every render, which is cheap for a list of hundreds. useMemo exists for when it genuinely is not — covered in the hooks article, and needed less often than people assume.

A state variable that is always computed from another is a bug waiting to happen.

useReducer

When several pieces of state change together, useState calls multiply and get out of step.

type ListState = {
status: 'loading' | 'success' | 'empty' | 'error';
students: Student[];
error: string | null;
page: number;
};

type ListAction =
| { type: 'loading' }
| { type: 'loaded'; students: Student[] }
| { type: 'failed'; message: string }
| { type: 'pageChanged'; page: number };

function listReducer(state: ListState, action: ListAction): ListState {
switch (action.type) {
case 'loading':
return { ...state, status: 'loading', error: null };

case 'loaded':
return {
...state,
status: action.students.length === 0 ? 'empty' : 'success',
students: action.students,
error: null
};

case 'failed':
return { ...state, status: 'error', error: action.message, students: [] };

case 'pageChanged':
return { ...state, page: action.page };

default: {
const exhaustive: never = action;
return exhaustive;
}
}
}
const [state, dispatch] = useReducer(listReducer, {
status: 'loading',
students: [],
error: null,
page: 1
});

dispatch({ type: 'loading' });
dispatch({ type: 'loaded', students: result.items });

Three advantages over several useState calls:

  • Every transition is in one place — reading the reducer tells you every way the state can change.
  • Impossible states are prevented. You cannot be status: 'error' with a populated list, because no action produces that.
  • It is a pure function, testable with no component and no DOM.

The never check makes adding an action type a compile error until it is handled.

Use useState for independent values, useReducer when they change together. Two or three related useState calls updated in the same handler is the signal.

Diagnosing state bugs

SymptomCause
The view does not updateState mutated in place, not replaced
A counter increments by one, not threeReading stale state instead of the functional updater
console.log shows the old valueState updates on the next render, not immediately
"changing an uncontrolled input to controlled"Initial state undefined instead of ''
A stray 0 on the page{count && …} with a zero count
The list order is destroyedsort or reverse on the state array
Row state moves to the wrong rowkey={index}
The count disagrees with the listDerived value stored as state
A handler runs on renderonClick={handler()} instead of onClick={handler}

React DevTools shows a component's current state and props, and highlights what re-rendered. It is the first tool to reach for, not the last.

Errors you will hit

What you seeCauseFix
State does not updateMutated it instead of replacingAlways set a new object or array
Two rapid updates lose oneRead stale stateUse the functional form setX(prev => ...)
The page reloads on submitDefault form behaviourevent.preventDefault()
Too many re-renderssetState called during renderMove it into a handler or effect
A handler runs immediatelyCalled it instead of passing itonClick={handle} not onClick={handle()}

onClick={handleDelete()} calls the function during render. It must be onClick={handleDelete} or onClick={() => handleDelete(id)}.

Common mistakes

  • Mutating state instead of replacing it
  • sort or reverse on the state array
  • Reading state immediately after setting it
  • Not using the functional updater when the new value depends on the old
  • useState([]) inferring never[]
  • Initialising a controlled input to undefined
  • onClick={handler()} calling on render
  • Forgetting event.preventDefault() on submit
  • Storing derived values as state
  • An expensive initialiser without the arrow form
  • Spread treated as a deep copy
  • Several useState calls where a reducer belongs

Practice

  1. Build a counter. Call setCount(count + 1) three times in one handler and confirm it increments by one.
  2. Switch to setCount(prev => prev + 1) and confirm it increments by three.
  3. Log the state immediately after setting it. Confirm the old value.
  4. Add a student with push and setStudents(students). Confirm no re-render, then fix it with a spread.
  5. Sort the state array with .sort(). Confirm no re-render and that the original order is gone.
  6. Update one student's class with map and a spread. Confirm only that row changes.
  7. Mutate a nested address.city through a shallow copy. Confirm the original changed.
  8. Initialise a text input's state to undefined, then set it. Record the controlled/uncontrolled warning.
  9. Write onClick={handleDelete()} and confirm it runs on render.
  10. Store filteredStudents as state alongside students. Change students without updating it and confirm they disagree.
  11. Replace it with a derived value and confirm they cannot disagree.
  12. Lift selected into a parent shared by a list and a detail panel.
  13. Convert four related useState calls into a useReducer with a discriminated union.
  14. Add a fifth action type and confirm the never check produces a compile error until handled.
  15. Write useState([]) and try to push a Student. Record the error, then type it.

Exercises 4, 5 and 10 correspond to three real defects.

You can now

  • Manage state and update it immutably
  • Use the functional update form when the new value depends on the old
  • Handle events and prevent default behaviour
  • Say why a handler ran on render
  • Lift state to the nearest common parent

Review questions

  1. Why does calling setCount(count + 1) three times increment by one?
  2. Why does mutating a state array not re-render the component?
  3. Why should derived values never be stored as state?
  4. When is useReducer better than several useState calls?

Next: Hooks and effects