State
By the end of this lesson
Model changing values so the UI follows from them.
State is a value a component remembers between renders and can change. A filter term the user typed, whether a panel is open, which employee is selected.
The reason it needs a special mechanism is that a component is a function. Local variables inside a function are gone when it returns, and reassigning one would not tell React that anything had changed. useState gives you a value that survives across renders, plus a function that changes it and asks React to render again.
In a Next.js application, a component that holds state needs a "use client" line at the top of its file. Take that as given for now — the Server and Client Components lesson explains what it means and why the default is the other way round.
"use client";
import { useState } from "react";
export function EmployeeDirectory({ employees }: { employees: Employee[] }) {
const [searchTerm, setSearchTerm] = useState("");
// Derived during render. Not state.
const matches = employees.filter((employee) =>
employee.name.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div>
<label htmlFor="employee-search">Search by name</label>
<input
id="employee-search"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
/>
<p className="text-sm text-slate-600">
{matches.length} of {employees.length} employees
</p>
<EmployeeTable employees={matches} />
</div>
);
}- useState returns a pair: the current value and a function that sets it. The array destructuring on the left is why you see two names in square brackets. The argument is the starting value, used on the first render only.
- Typing into the input calls setSearchTerm. React stores the new value and renders the component again. On that render, searchTerm is the new string.
- matches is computed from employees and searchTerm every render. It is not state, and putting it in state would create a second thing to keep in step with the first.
- The count paragraph reads matches.length. Nobody updates that number when the search changes — it is recalculated because the whole component runs again. That is the shift in thinking: you describe the screen for the current state instead of updating parts of it.
- The label is tied to the input with htmlFor and id. Without that, a screen reader announces an unlabelled text box, and clicking the label does not focus the field.
const [pageSize, setPageSize] = useState(10);
// Broken. Both calls read the same pageSize from this render, which is 10.
// Both therefore set it to 11, and the second overwrites the first.
function showTwoMore() {
setPageSize(pageSize + 1);
setPageSize(pageSize + 1);
}
// Correct. Each updater receives the value React has pending,
// so they apply one after the other and the result is 12.
function showTwoMoreCorrectly() {
setPageSize((current) => current + 1);
setPageSize((current) => current + 1);
}- Calling the setter does not change the variable you are holding. pageSize is a value captured for this render, and it keeps that value until the next render replaces it. Reading it straight after setting it gives you the old number — this catches nearly everyone once.
- React also batches updates. Several setter calls from the same event are collected and applied together, then the component renders once. That is why the first version loses an update instead of rendering twice.
- The updater form — a function passed to the setter — is called by React with the latest pending value. Use it whenever the new value depends on the old one: counters, toggles, adding to a list, anything cumulative.
- When the new value is independent of the old, setSearchTerm(event.target.value) is direct and clear. There is no need to reach for an updater to satisfy a rule.
const [filters, setFilters] = useState({ department: "all", includeLeavers: false });
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// Wrong: changes the existing object. React compares by reference,
// sees the same object, and may render nothing at all.
function chooseDepartmentWrongly(department: string) {
filters.department = department;
setFilters(filters);
}
// Right: build a new object with the change applied.
function chooseDepartment(department: string) {
setFilters((current) => ({ ...current, department }));
}
// Right: non-mutating array operations return new arrays.
function select(id: string) {
setSelectedIds((current) => [...current, id]);
}
function deselect(id: string) {
setSelectedIds((current) => current.filter((item) => item !== id));
}- React decides whether state changed by comparing the old value with the new one by identity. Mutate an object in place and both sides are the same object, so nothing is considered changed.
- The spread copies the existing keys into a new object, then the later key wins. { ...current, department } reads as "everything as it was, with a different department".
- filter, map, slice and concat return new arrays. push, splice, sort and reverse change the array in place — if you need a sorted copy, sort a spread of it, or use toSorted where your runtime supports it.
- The spread is shallow. A nested object is still shared with the previous state, so updating a value two levels down needs a copy at each level. When that becomes awkward, it is a sign the state shape should be flatter.
The most common structural mistake is storing something you could calculate. Two pieces of state that must agree will eventually disagree:
| Stored twice | Derived from one source | |
|---|---|---|
| The state | employees, searchTerm, and filteredEmployees | employees and searchTerm |
| What has to happen on a keystroke | Set the term, then remember to recalculate and set the filtered list | Set the term. The filtered list is recalculated on render |
| When an employee is added | Two updates, in the right order, in every place that adds one | One update |
| Failure mode | A stale list that does not match the search box, in whichever code path forgot the second update | None available — there is nothing to fall out of step |
| Cost | Every future change has to maintain the invariant | The filter runs on each render, which is negligible for hundreds of rows |
Summary
- State is a value React keeps for a component instance; the setter records it and schedules a re-render
- State is fixed for the duration of a render, so use the updater form when the new value depends on the old
- Updates from one event are batched, which is why two direct increments produce one
- Never mutate state — build a new object or array so React can see the change by identity
- Derive whatever you can during render; two values that must agree will eventually disagree
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Try it yourself
Add a department filter to the directory above. Keep both the search term and the chosen department in state, and show the employees matching both.
Then add a "Clear filters" button. Before you write it, decide how many state updates it should make and why.
Show solution
Two pieces of state, one derived list. The list is not state, so clearing the filters needs no code to rebuild it — resetting the two values is enough and the list follows.
Whether to keep the term and the department as one object or two useState calls is a real decision. Two calls are simpler to read and update independently. One object is better when the values always change together, or when you want to reset them in a single update as the clear button does. Both are defensible; the object version is shown because the clear case makes it slightly cleaner.
Note that the department list is derived from the employee data rather than hardcoded. A hardcoded list is a second copy of information the data already contains, and it goes stale the first time a department is renamed.
"use client";
import { useState } from "react";
const initialFilters = { term: "", department: "all" };
export function EmployeeDirectory({ employees }: { employees: Employee[] }) {
const [filters, setFilters] = useState(initialFilters);
const departments = [...new Set(employees.map((employee) => employee.department))].sort();
const matches = employees.filter((employee) => {
const matchesTerm = employee.name.toLowerCase().includes(filters.term.toLowerCase());
const matchesDepartment =
filters.department === "all" || employee.department === filters.department;
return matchesTerm && matchesDepartment;
});
return (
<div>
<label htmlFor="term">Search by name</label>
<input
id="term"
value={filters.term}
onChange={(event) =>
setFilters((current) => ({ ...current, term: event.target.value }))
}
/>
<label htmlFor="department">Department</label>
<select
id="department"
value={filters.department}
onChange={(event) =>
setFilters((current) => ({ ...current, department: event.target.value }))
}
>
<option value="all">All departments</option>
{departments.map((department) => (
<option key={department} value={department}>
{department}
</option>
))}
</select>
<button type="button" onClick={() => setFilters(initialFilters)}>
Clear filters
</button>
<EmployeeTable employees={matches} />
</div>
);
}Think about it
Think about it
A team keeps three pieces of state for a directory: allEmployees, visibleEmployees, and visibleCount. Every filter change updates all three.
List the bugs this invites. Then decide which of the three should exist.
Show solution
Only allEmployees should be state, alongside whatever describes the filter. visibleEmployees is a filter over allEmployees, and visibleCount is that list's length.
The bugs follow from the duplication. A new code path that adds an employee and forgets visibleEmployees shows a stale list. A path that updates visibleEmployees but not visibleCount shows a count that contradicts the rows underneath it. A reset that clears two of the three leaves an impossible combination on screen. Each is a separate bug report, and each fix is another place that has to remember the invariant.
The general rule: keep the smallest set of values from which everything else can be calculated, and calculate the rest during render. When two values must always agree, the reliable way to guarantee it is to only have one of them.
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.