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
valueprop to a form field without anonChangehandler.
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
| Symptom | Cause |
|---|---|
| The view does not update | State mutated in place, not replaced |
| A counter increments by one, not three | Reading stale state instead of the functional updater |
console.log shows the old value | State 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 destroyed | sort or reverse on the state array |
| Row state moves to the wrong row | key={index} |
| The count disagrees with the list | Derived value stored as state |
| A handler runs on render | onClick={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 see | Cause | Fix |
|---|---|---|
| State does not update | Mutated it instead of replacing | Always set a new object or array |
| Two rapid updates lose one | Read stale state | Use the functional form setX(prev => ...) |
| The page reloads on submit | Default form behaviour | event.preventDefault() |
Too many re-renders | setState called during render | Move it into a handler or effect |
| A handler runs immediately | Called it instead of passing it | onClick={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
sortorreverseon the state array- Reading state immediately after setting it
- Not using the functional updater when the new value depends on the old
useState([])inferringnever[]- 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
useStatecalls where a reducer belongs
Practice
- Build a counter. Call
setCount(count + 1)three times in one handler and confirm it increments by one. - Switch to
setCount(prev => prev + 1)and confirm it increments by three. - Log the state immediately after setting it. Confirm the old value.
- Add a student with
pushandsetStudents(students). Confirm no re-render, then fix it with a spread. - Sort the state array with
.sort(). Confirm no re-render and that the original order is gone. - Update one student's class with
mapand a spread. Confirm only that row changes. - Mutate a nested
address.citythrough a shallow copy. Confirm the original changed. - Initialise a text input's state to
undefined, then set it. Record the controlled/uncontrolled warning. - Write
onClick={handleDelete()}and confirm it runs on render. - Store
filteredStudentsas state alongsidestudents. Changestudentswithout updating it and confirm they disagree. - Replace it with a derived value and confirm they cannot disagree.
- Lift
selectedinto a parent shared by a list and a detail panel. - Convert four related
useStatecalls into auseReducerwith a discriminated union. - Add a fifth action type and confirm the
nevercheck produces a compile error until handled. - Write
useState([])and try to push aStudent. 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
- Why does calling
setCount(count + 1)three times increment by one? - Why does mutating a state array not re-render the component?
- Why should derived values never be stored as state?
- When is
useReducerbetter than severaluseStatecalls?
Next: Hooks and effects