Skip to main content
ANVISoftware Solutions
Lesson 6 of 18Intermediate15 min

Hooks

By the end of this lesson

Use the common hooks and respect the rules that govern them.

A hook is a function that lets a component use one of React's features. useState is a hook. So are useRef, useMemo, useCallback and useEffect.

They all share a naming convention — a name beginning with "use" — and a pair of rules that look arbitrary until you know how React stores what they hold. This lesson covers the common hooks and then explains the rules by explaining the mechanism, because the rules are impossible to remember otherwise and easy to remember once the mechanism is clear.

The hooks you will use most, and what each is actually for:

useState
A value the component remembers, and changing it triggers a render. Reach for this first; most components need nothing else.
useRef
A container whose .current property you can read and write without causing a render. Two uses: holding a reference to a DOM element so you can focus or measure it, and remembering a value between renders that nothing on screen depends on, such as a timer id.
useMemo
Remembers the result of a calculation and repeats it only when its inputs change. A performance tool, not a correctness tool — the code has to be correct without it.
useCallback
The same idea for a function: returns the same function instance between renders while its inputs are unchanged. It matters when the function is a dependency of another hook or a prop to a memoised child.
useId
Generates a stable unique string for this component instance. Use it for the id that connects a label to an input, so a component can appear twice on a page without duplicate ids.
useEffect
Runs code that reaches outside React after a render. It is the hook most often used where none is needed, so it has a lesson of its own — the next one.
EmployeeSearch.tsx — useRef for a DOM element, useId for a label
TSX
"use client";

import { useId, useRef, useState } from "react";

export function EmployeeSearch({ onSearch }: { onSearch: (term: string) => void }) {
  const [term, setTerm] = useState("");
  const inputRef = useRef<HTMLInputElement>(null);
  const inputId = useId();

  function handleClear() {
    setTerm("");
    onSearch("");
    // Move focus back to the field so a keyboard user is not stranded
    // on a button that has just become irrelevant.
    inputRef.current?.focus();
  }

  return (
    <div>
      <label htmlFor={inputId}>Search employees</label>
      <input
        id={inputId}
        ref={inputRef}
        value={term}
        onChange={(event) => {
          setTerm(event.target.value);
          onSearch(event.target.value);
        }}
      />
      {term !== "" && (
        <button type="button" onClick={handleClear}>
          Clear
        </button>
      )}
    </div>
  );
}
  • useRef<HTMLInputElement>(null) creates the container. Passing it to the ref attribute makes React put the real input element into .current once it exists in the DOM.
  • The optional chaining in inputRef.current?.focus() is not decoration. current is null before the element exists, and TypeScript requires you to account for that.
  • Writing to a ref never causes a render. That is exactly what you want for focus, and exactly what makes a ref the wrong place for anything displayed on screen — change it and nothing updates.
  • useId gives this instance its own id, so two search boxes on one page still have correctly associated labels. Never use it as a key for list items; it identifies a component, not a piece of data.
  • The clear button returns focus deliberately. When a control disappears after being activated, focus otherwise falls back to the top of the document, and a keyboard user has to navigate all the way back.
The same component, wrong and right
TSX
// Wrong. On renders where employees is empty, the component returns before
// reaching useState, so the hook count differs between renders.
function EmployeeListBroken({ employees }: { employees: Employee[] }) {
  if (employees.length === 0) return <p>No employees.</p>;

  const [selectedId, setSelectedId] = useState<string | null>(null);
  // ...
}

// Also wrong, for the same reason: the hook is inside a condition.
function EmployeeListAlsoBroken({ employees, canSelect }: Props) {
  if (canSelect) {
    const [selectedId, setSelectedId] = useState<string | null>(null);
  }
  // ...
}

// Right. Every hook runs on every render; the condition lives in the markup.
function EmployeeList({ employees }: { employees: Employee[] }) {
  const [selectedId, setSelectedId] = useState<string | null>(null);

  if (employees.length === 0) return <p>No employees.</p>;

  return <EmployeeTable employees={employees} selectedId={selectedId} onSelect={setSelectedId} />;
}
  • The fix is mechanical: move every hook call above any early return, and put the condition in what you render rather than in whether you call the hook.
  • An unused piece of state on a render that returns early costs nothing worth measuring. The consistency is what React needs.
  • The ESLint rule that ships with React catches all three of these while you type. Leave it switched on — it is one of the few lint rules that reports genuine bugs rather than style preferences.
A custom hook is a function that calls other hooks
TSX
"use client";

import { useMemo, useState } from "react";

export function useEmployeeFilter(employees: Employee[]) {
  const [term, setTerm] = useState("");
  const [department, setDepartment] = useState("all");

  const matches = useMemo(() => {
    const lowerTerm = term.toLowerCase();
    return employees.filter(
      (employee) =>
        employee.name.toLowerCase().includes(lowerTerm) &&
        (department === "all" || employee.department === department)
    );
  }, [employees, term, department]);

  return { term, setTerm, department, setDepartment, matches };
}
  • There is nothing special about a custom hook. It is a function whose name starts with use and which calls hooks, so the rules apply to it as they do to a component.
  • It packages the two filter values and the result of combining them. A component using it gets the whole filter behaviour in one line, and two different screens can share it without sharing markup.
  • Each component calling this hook gets its own independent state. A custom hook shares logic, not data — if two screens must see the same filter, that state has to live somewhere they both read from.
  • The useMemo here is borderline. For a few hundred employees the filter is fast enough that plain code is preferable. It earns its place when the list is large or the work per item is heavy, and the performance lesson covers how to tell which case you are in rather than guessing.

Summary

  • A hook is a function giving a component access to a React feature; the name begins with use
  • React matches hooks to stored values by call order, which is why they must be called at the top level every render
  • useState for values the screen depends on, useRef for values it does not, useId for label and input associations
  • useMemo and useCallback are performance tools with a real cost, not defaults
  • A custom hook shares logic, not data — every call site gets its own independent state

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Try it yourself

Write a useDisclosure hook that returns an isOpen boolean plus open, close and toggle functions. Use it for an employee detail panel.

Then use it twice in the same component, for two independent panels, and confirm they do not affect each other.

Show solution

Two calls give two independent pieces of state, because React allocates a slot per call site in the component. This is the most useful thing to understand about custom hooks: they package behaviour, and each use gets its own copy of the data.

toggle uses the updater form. It has to, because the new value is derived from the old one — writing setIsOpen(!isOpen) works until two toggles land in the same event, at which point it loses one.

The returned object is built fresh on every render. That is fine here, and would matter only if it were passed to a memoised child, in which case the individual functions would need useCallback. Adding that now would be optimising something nobody has measured.

TSX
"use client";

import { useState } from "react";

export function useDisclosure(initiallyOpen = false) {
  const [isOpen, setIsOpen] = useState(initiallyOpen);

  return {
    isOpen,
    open: () => setIsOpen(true),
    close: () => setIsOpen(false),
    toggle: () => setIsOpen((current) => !current),
  };
}

// Two independent panels in one component:
function EmployeeDetail({ employee }: { employee: Employee }) {
  const contact = useDisclosure(true);
  const history = useDisclosure();

  return (
    <div>
      <button type="button" onClick={contact.toggle} aria-expanded={contact.isOpen}>
        Contact details
      </button>
      {contact.isOpen && <ContactPanel employee={employee} />}

      <button type="button" onClick={history.toggle} aria-expanded={history.isOpen}>
        Role history
      </button>
      {history.isOpen && <RoleHistoryPanel employee={employee} />}
    </div>
  );
}

Think about it

Think about it

A component calls useState three times, then someone wraps the second call in an if statement so it only runs for administrators.

Walk through what React does on a render where the condition is false, and explain why the symptom often shows up as the third piece of state behaving strangely.

Show solution

On a render where the condition is true, there are three calls: slots one, two and three. On a render where it is false, there are two calls — and the second call in the source is now the third piece of state, so it reads slot two.

That is why the third value misbehaves rather than the second. The hook that was skipped is not the one that looks broken; every hook after it shifts up a slot and silently receives the wrong stored value. React frequently detects the count mismatch and throws, but when the counts happen to line up, you get quietly swapped values instead.

The fix is not to make the condition cleverer. Call all three unconditionally and use the condition where it belongs — in what you render, or in what you do with the value. Alternatively, split the administrator behaviour into its own component, which gets its own hook list.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

Why must hooks be called in the same order on every render?
You need to remember the id returned by setTimeout so you can cancel it later. Nothing on screen shows it. Which hook fits?

Saved in this browser only.