Skip to main content
ANVISoftware Solutions
Lesson 5 of 18Intermediate16 min

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.

EmployeeDirectory.tsx — one piece of state driving the screen
TSX
"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.
Two ways to set state, and why one of them is wrong here
TSX
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.
Updating objects and arrays without mutating them
TSX
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 twiceDerived from one source
The stateemployees, searchTerm, and filteredEmployeesemployees and searchTerm
What has to happen on a keystrokeSet the term, then remember to recalculate and set the filtered listSet the term. The filtered list is recalculated on render
When an employee is addedTwo updates, in the right order, in every place that adds oneOne update
Failure modeA stale list that does not match the search box, in whichever code path forgot the second updateNone available — there is nothing to fall out of step
CostEvery future change has to maintain the invariantThe 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.

TSX
"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.

A click handler calls setCount(count + 1) twice in a row, starting from 5. What is count on the next render?
Why does pushing a new employee onto an array held in state often fail to update the screen?

Saved in this browser only.