Hooks with Types

Expert · 19 min read · ▶ live playground · ✦ checkpoint

Hooks move typed component boundaries inward. Props describe what a parent may pass; hooks describe the state, refs, and actions a component keeps while React renders it over time.

This is another tool-shaped React lesson. The TSX examples are read-only reference code for a real React project. The live playground uses plain TypeScript to check reducer actions without JSX.

useState starts with inference

Most useState calls should not need a type argument. If the initial value is informative, TypeScript can infer the state shape and the setter will enforce it.

Read-only reference code:

import { useState } from "react";
 
type SaveStatus = "idle" | "saving" | "saved" | "failed";
 
type TagEditorProps = {
  initialTags?: string[];
};
 
export function TagEditor({ initialTags = [] }: TagEditorProps) {
  const [tags, setTags] = useState<string[]>(initialTags);
  const [draft, setDraft] = useState("");
  const [status, setStatus] = useState<SaveStatus>("idle");
 
  function addTag() {
    const label = draft.trim();
    if (label === "") {
      return;
    }
 
    setTags((current) => [...current, label]);
    setDraft("");
    setStatus("saving");
  }
 
  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        addTag();
      }}
    >
      <label>
        New tag
        <input value={draft} onChange={(event) => setDraft(event.currentTarget.value)} />
      </label>
      <button type="submit">Add</button>
      <p>Status: {status}</p>
      <ul>
        {tags.map((tag) => (
          <li key={tag}>{tag}</li>
        ))}
      </ul>
    </form>
  );
}

draft is easy: useState("") gives string. The setter accepts either a new string or an updater function that receives the previous string.

tags is the first case where the type argument earns its keep. An empty array has no element evidence, so useState([]) can infer never[]. That means "an array that can never contain anything," which is almost never what you intended. If the state starts empty, pass the element type: useState<string[]>([]).

status is the other common case. If the component has a closed list of allowed states, pass the literal union so "loading-but-not-really" cannot sneak into the setter later. For ordinary booleans, numbers, and strings, inference is usually enough; for empty collections, null starters, and literal unions, tell the hook the intended state type.

The updater form matters when the next state depends on the previous state. setTags((current) => [...current, label]) asks React for the current value at update time, then returns the next array. That is both a React correctness habit and a TypeScript habit: current is typed as string[], so the returned array must still be string[].

The same rule handles "not loaded yet" state. If a selected user starts absent, write useState<User | null>(null), then narrow before reading user fields. The type argument is not ceremony; it is the promise that this slot will eventually hold either a real User or null, and nothing else.

Refs hold things between renders

A ref is a stable object whose .current value can change without causing a re-render. That makes refs useful, but also easy to misuse. Keep the three common flavors separate:

  • A DOM node ref points at an element React mounted for you.
  • A value ref stores a timer id, previous value, or other mutable value that should not re-render the component.
  • A callback ref is a function React calls with a node when you need setup work at attach/detach time.

Read-only reference code:

import { useCallback, useRef, useState } from "react";
 
export function SearchBox() {
  const wrapperRef = useRef<HTMLDivElement>(null);
  const debounceTimer = useRef<number | null>(null);
  const [query, setQuery] = useState("");
 
  const rememberInput = useCallback((node: HTMLInputElement | null) => {
    if (node === null) {
      return;
    }
 
    node.setAttribute("aria-label", "Search projects");
  }, []);
 
  function scrollToSearch() {
    wrapperRef.current?.scrollIntoView({ block: "nearest" });
  }
 
  function updateQuery(value: string) {
    setQuery(value);
 
    if (debounceTimer.current !== null) {
      window.clearTimeout(debounceTimer.current);
    }
 
    debounceTimer.current = window.setTimeout(() => {
      console.log("search", value);
    }, 250);
  }
 
  return (
    <section ref={wrapperRef}>
      <button type="button" onClick={scrollToSearch}>
        Jump to search
      </button>
      <input ref={rememberInput} value={query} onChange={(event) => updateQuery(event.currentTarget.value)} />
    </section>
  );
}

The generic on the DOM ref names the element you expect: HTMLDivElement. The initial value is null because React has not mounted the element during the first render. That is why the code uses optional chaining before calling scrollIntoView.

The timer ref is not a DOM ref at all. It is just a stable box for a browser timer id. Updating debounceTimer.current will not redraw the UI, which is exactly right for bookkeeping. If the value should affect what the user sees, it belongs in state instead.

The callback ref is the third shape: React calls it with HTMLInputElement | null, so the function must handle the detach case. This is still a typed ref; it just uses a function instead of an object with .current.

That distinction prevents a common design mistake. Refs are for reaching out to an element or remembering something between renders. They are not a quieter version of state. If changing a value should change the JSX branch, a class name, a disabled button, or visible text, put it in state so React knows to render again. If changing a value should only support cleanup, focus, measuring, or integration with an external API, a ref is usually the right tool.

Reducers make action types visible

When a component has several state transitions, useReducer can make the transition list clearer than a pile of setters. The important TypeScript move is the same one you used for component modes: use a discriminated union for actions.

The reducer itself is plain TypeScript:

typescript — playgroundtype-checked
⌘/Ctrl + Enter to run

In React, useReducer reuses that same reducer and gives you a typed dispatch.

Read-only reference code:

import { useReducer } from "react";
 
type WizardState = {
  step: number;
  email: string;
};
 
type WizardAction =
  | { type: "next" }
  | { type: "back" }
  | { type: "setEmail"; email: string }
  | { type: "reset" };
 
function wizardReducer(state: WizardState, action: WizardAction): WizardState {
  switch (action.type) {
    case "next":
      return { ...state, step: Math.min(state.step + 1, 3) };
    case "back":
      return { ...state, step: Math.max(state.step - 1, 1) };
    case "setEmail":
      return { ...state, email: action.email };
    case "reset":
      return { step: 1, email: "" };
  }
}
 
export function CheckoutWizard() {
  const [state, dispatch] = useReducer(wizardReducer, { step: 1, email: "" });
 
  return (
    <section>
      <p>Step {state.step} of 3</p>
      <label>
        Email
        <input
          value={state.email}
          onChange={(event) => dispatch({ type: "setEmail", email: event.currentTarget.value })}
        />
      </label>
      <button type="button" onClick={() => dispatch({ type: "back" })}>
        Back
      </button>
      <button type="button" onClick={() => dispatch({ type: "next" })}>
        Next
      </button>
      <button type="button" onClick={() => dispatch({ type: "reset" })}>
        Reset
      </button>
    </section>
  );
}

The action union is the checklist. If an action carries data, like "setEmail", that branch requires the data. If an action carries no data, dispatching extra payload is a design question you can make visible by changing the union.

Reducers also make review easier because the state transitions are named. A setter named setState can be called from anywhere with any full replacement state. A dispatch call has to choose one of the action names, and the reducer centralizes what that action means. Keeping the reducer return type explicit gives TypeScript another place to object if a future action branch forgets to return a valid WizardState.

Custom hooks should return stable shapes

A custom hook is just a function that uses hooks, but its return type becomes another public boundary. Arrays are convenient for names chosen by the caller, but plain array inference is too wide for mixed values. as const turns the return into a readonly tuple.

Read-only reference code:

import { useState } from "react";
 
type DisclosureControls = {
  open: () => void;
  close: () => void;
  toggle: () => void;
};
 
export function useDisclosure(initialOpen = false) {
  const [isOpen, setIsOpen] = useState(initialOpen);
 
  const controls: DisclosureControls = {
    open: () => setIsOpen(true),
    close: () => setIsOpen(false),
    toggle: () => setIsOpen((current) => !current),
  };
 
  return [isOpen, controls] as const;
}
 
export function DetailsPanel() {
  const [isOpen, disclosure] = useDisclosure();
 
  return (
    <section>
      <button type="button" onClick={disclosure.toggle}>
        Toggle details
      </button>
      {isOpen ? (
        <div>
          <p>Details are visible.</p>
          <button type="button" onClick={disclosure.close}>
            Close
          </button>
        </div>
      ) : null}
    </section>
  );
}

Without as const, TypeScript tends to infer a general array whose elements are "boolean or controls." Destructuring then loses the position information: the first item should always be the boolean, and the second should always be the controls object. The const assertion preserves that tuple contract without writing a separate return type.

You can also return an object from a custom hook, and that is often clearer when there are many fields. Tuples shine when the positions are few and familiar, like React's own [value, setValue] shape. Once a tuple grows past two or three entries, names usually beat positions. The type system can support either style; your job is to make the caller's destructuring hard to misunderstand.

Next lesson keeps the hook vocabulary and moves to wider component patterns: context defaults, generic components, and typed form/event helpers.

Checkpoint

Answer all three to mark this lesson complete

+50 XP on completion