Contents
18 Chapter

Typing hooks — useState · useReducer · useRef

Type inference and when to be explicit with built-in hooks. The discriminated-union action with useReducer, the two flavors of useRef (DOM ref and value box), and React 19 ref-as-prop, all in one chapter.

In Chapter 17 we covered how to put types on a component’s interface. This chapter is about the inside of the component — how to handle the types of built-in hooks.

We start with one big principle:

Leave it to inference where inference works, and only be explicit where inference falls short.

The same principle applies to every hook. The discriminated-union model from Chapter 17 shows up again with useReducer in this chapter. And the JavaScript patterns from Chapters 5 and 13 (Custom hooks) are put back on top of TypeScript here.

useState — inferred from the initial value #

Start with the most common pattern.

useState basics — inference
import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  // count is number, setCount is (n: number | ((prev: number) => number)) => void

  return (
    <button onClick={() => setCount((c) => c + 1)}>{count}</button>
  );
}

Just useState(0) infers count: number. setCount is automatically caught as Dispatch<SetStateAction<number>>, so both setCount(1) and setCount((c) => c + 1) work correctly.

When the initial value gets inferred too narrowly #

Literal initial values are sometimes inferred too narrowly.

inferred narrowly — not what we meant
const [status, setStatus] = useState('idle');
// inferred type: string

setStatus('loading');  // OK
setStatus('done');     // OK
setStatus('foo');      // OK — not what we wanted

If you want to allow only 'idle', 'loading', and 'done', you need a type argument.

explicit union type
type Status = 'idle' | 'loading' | 'done';
const [status, setStatus] = useState<Status>('idle');

setStatus('done');     // OK
setStatus('foo');      // ✗

When the initial value is null #

If you give null as the initial value, the inference just becomes null, which is not useful. Always declare the possible shapes as a type argument.

state that starts as null
type User = { id: string; name: string };

const [user, setUser] = useState<User | null>(null);

setUser({ id: 'u1', name: 'Curtis' });   // OK
setUser(null);                            // OK (sign out)

This pattern is very common for the “before data loaded” state. You will run into it more in Chapter 21 (Typing fetch and API responses) as a form state.

The same rule applies to a lazy initializer #

When you build the initial value with a function, the return type of that function is the state type.

lazy init
const [todos, setTodos] = useState<Todo[]>(() => loadFromStorage());

If loadFromStorage() already returns Todo[], you can drop the type argument. If the return type is fuzzy or unknown, declaring it explicitly is the safer move.

useReducer — narrowing the action is the real payoff #

useState fits simple changes; useReducer shines when you collect many kinds of changes in one place. With TypeScript, you catch the action with a discriminated union and the reducer narrows naturally. It is the strongest application of the union pattern from Chapter 17.

reducer pattern
type State = {
  count: number;
};

type Action =
  | { type: 'increment' }
  | { type: 'decrement' }
  | { type: 'set'; value: number };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    case 'set':
      return { count: action.value };  // value is visible only here
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, { count: 0 });

  return (
    <>
      <span>{state.count}</span>
      <button onClick={() => dispatch({ type: 'increment' })}>+1</button>
      <button onClick={() => dispatch({ type: 'set', value: 100 })}>=100</button>
    </>
  );
}

The key is that Action is a discriminated union.

  • Inside the reducer’s switch, action.value is visible only in the action.type === 'set' branch.
  • Forgetting required payload like dispatch({ type: 'set' }) produces a compile error.
  • Typos like dispatch({ type: 'unknown' }) are caught too.

This safety net is essentially impossible in JavaScript. Once you start using reducers, this is one of the cases where TypeScript shines the most.

Exhaustiveness check with never #

If you add a new action but forget to handle it in the reducer, how do you find out? Using never in the default branch catches it at compile time.

exhaustiveness check
function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    case 'set':
      return { count: action.value };
    default: {
      const _exhaustive: never = action;  // forget a new action and this errors
      return state;
    }
  }
}

If you add a new kind to Action and the reducer does not handle it, the line _exhaustive: never lights up. The risk of missing a case is blocked at compile time.

useRef — two uses, two typing patterns #

useRef has two typing patterns depending on your intent. The two flavors of ref we touched on briefly in Chapter 13 (Custom hooks) get nailed down with exact types here.

1) DOM node ref #

A ref attached directly to an element starts as null and tells the type argument which element it points at.

DOM ref
import { useRef, useEffect } from 'react';

function AutoFocusInput() {
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    inputRef.current?.focus();
  }, []);

  return <input ref={inputRef} type="text" />;
}

The type of inputRef.current is HTMLInputElement | null. Before mount it is null, so you need optional chaining (?.).

2) A mutable value box #

When you want “a value that stays the same object across renders” — a setInterval ID, remembering a previous prop, and so on — you also use a ref. Here you give the initial value as-is and let inference do its work.

value box ref
function Timer() {
  const startedAt = useRef<number>(Date.now());
  // .current is always number — no null check needed

  return <span>{startedAt.current}</span>;
}

Unlike a DOM ref, .current is never null here. The initial value itself was a meaningful value.

UseInitial value.current typenull check
DOM nodenullT | nullneeded (?.)
value boxa meaningful valuethe type of that valuenot needed

Older material often used a separate type like MutableRefObject to distinguish the two uses. Modern @types/react distinguishes them automatically based on the initial value, so you rarely have to think about it.

Taking a ref as a prop (React 19) #

When a parent needs to attach a ref to a child’s DOM, the old model used forwardRef. From React 19 onward you can just take ref as a prop, which is simpler.

ref as a prop (React 19)
import type { Ref } from 'react';

type InputProps = {
  ref?: Ref<HTMLInputElement>;
  placeholder?: string;
};

function Input({ ref, placeholder }: InputProps) {
  return <input ref={ref} placeholder={placeholder} />;
}

// in the parent
function Form() {
  const inputRef = useRef<HTMLInputElement>(null);
  return <Input ref={inputRef} placeholder="name" />;
}

forwardRef itself still works, but this book uses only the pattern above for new code. The procedure to move old forwardRef code over to ref-as-prop is covered in Appendix A (Migrating Old React). Chapter 28 (React 19 features) revisits the full shape of this model change.

useCallback — the signature is preserved #

useCallback is for keeping a function’s reference identity stable. There is almost nothing extra to do from a type standpoint. The type of the function you pass in is inferred as-is.

useCallback inference
import { useCallback, useState } from 'react';

function SearchBar() {
  const [query, setQuery] = useState('');

  const handleChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      setQuery(e.target.value);
    },
    []
  );

  return <input value={query} onChange={handleChange} />;
}

handleChange is inferred as (e: React.ChangeEvent<HTMLInputElement>) => void. No explicit type needed.

Event types are covered more thoroughly in the next Chapter 19 Typing events and forms. For now, just remember “you write the parameter type of an event handler function inside the handler”.

useMemo — the value’s type is inferred as-is #

The same goes for useMemo. The type of the value built inside becomes the return type as-is.

useMemo inference
import { useMemo } from 'react';

type Todo = { id: string; text: string; done: boolean };

function TodoStats({ todos }: { todos: Todo[] }) {
  const stats = useMemo(() => {
    return {
      total: todos.length,
      done: todos.filter((t) => t.done).length,
    };
  }, [todos]);

  // stats: { total: number; done: number }
  return <p>{stats.done} / {stats.total}</p>;
}

You can trust inference. Explicit types are almost never needed; if you do need one, you can give a type argument like useMemo<{ total: number; done: number }>(...).

Custom hooks — return shape: tuple vs object #

When you compose built-in hooks into a custom hook (see Chapter 13), the question of how to return often comes up. There is no single right answer, but the following two guidelines help.

1) Two or three values, and you want the call site to name them freely — tuple

tuple return + as const
function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn((v) => !v), []);

  return [on, toggle] as const;
}

// the call site freely names them
const [isOpen, toggleOpen] = useToggle();

The key is as const. Without it, the return is inferred as (boolean | (() => void))[] and destructuring mixes the types. With as const, it becomes exactly the tuple [boolean, () => void].

2) Four or more values, or when names carry clear meaning — object

object return
function useTodos() {
  const [items, setItems] = useState<Todo[]>([]);

  const add = useCallback((text: string) => { /* ... */ }, []);
  const remove = useCallback((id: string) => { /* ... */ }, []);
  const toggle = useCallback((id: string) => { /* ... */ }, []);

  return { items, add, remove, toggle };
}

// at the call site
const { items, add, remove } = useTodos();

Most non-trivial custom hooks read better as objects. The names carry the meaning directly. As a rough rule, stick to a tuple only when the shape matches a two-return built-in like useState or useReducer.

Try it yourself #

Let’s write a small Todo management hook with useReducer in TypeScript.

src/useTodoReducer.ts:

src/useTodoReducer.ts
import { useReducer } from 'react';

type Todo = {
  id: string;
  text: string;
  done: boolean;
};

type State = {
  todos: Todo[];
};

type Action =
  | { type: 'add'; text: string }
  | { type: 'toggle'; id: string }
  | { type: 'remove'; id: string }
  | { type: 'clear' };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'add':
      return {
        todos: [
          ...state.todos,
          { id: crypto.randomUUID(), text: action.text, done: false },
        ],
      };
    case 'toggle':
      return {
        todos: state.todos.map((t) =>
          t.id === action.id ? { ...t, done: !t.done } : t
        ),
      };
    case 'remove':
      return { todos: state.todos.filter((t) => t.id !== action.id) };
    case 'clear':
      return { todos: [] };
    default: {
      const _exhaustive: never = action;
      return state;
    }
  }
}

export function useTodoReducer() {
  return useReducer(reducer, { todos: [] });
}

src/TodoApp.tsx:

src/TodoApp.tsx
import { useState } from 'react';
import { useTodoReducer } from './useTodoReducer';

function TodoApp() {
  const [{ todos }, dispatch] = useTodoReducer();
  const [text, setText] = useState('');

  return (
    <div style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px' }}>
      <h2>Todos</h2>
      <form onSubmit={(e) => {
        e.preventDefault();
        if (!text) return;
        dispatch({ type: 'add', text });
        setText('');
      }}>
        <input value={text} onChange={(e) => setText(e.target.value)} placeholder="todo" />
        <button type="submit">Add</button>
        <button type="button" onClick={() => dispatch({ type: 'clear' })}>Clear all</button>
      </form>
      <ul>
        {todos.map((t) => (
          <li key={t.id}>
            <input type="checkbox" checked={t.done} onChange={() => dispatch({ type: 'toggle', id: t.id })} />
            <span style={{ textDecoration: t.done ? 'line-through' : 'none' }}>{t.text}</span>
            <button onClick={() => dispatch({ type: 'remove', id: t.id })}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default TodoApp;

Save and confirm it works. Then add a new kind to Action like { type: 'reorder'; from: number; to: number }, leave it unhandled in the reducer, and try to build. The line _exhaustive: never gets a red underline and the build is blocked.

Exercises #

  1. Add one more Action ({ type: 'rename'; id: string; text: string }) to useTodoReducer above and handle it in the reducer so the build passes again. Get the feel of how the exhaustiveness check catches missing cases.
  2. Make the Status union of useState<Status>('idle') 'idle' | 'loading' | 'success' | 'error', and inside <StatusBadge status={status} /> branch on all four states. Confirm how the red underline shows up when you miss a case (use the never pattern).
  3. Compare the two uses of useRef. Inside an <AutoFocusInput /> component, attach a ref to the input to focus on mount, and at the same time use useRef<number>(Date.now()) to record the time the component was created and display it on screen. Hover the two refs in the editor and compare how the .current types differ (HTMLInputElement | null vs number).

In one line: useState infers from the initial value. Declare a type argument when starting with null or when narrowing a union. With useReducer, catch actions as a discriminated union and use never for an exhaustiveness check. useRef has two patterns: a DOM one (starts at null) and a value box (a meaningful initial value). In React 19 you can take ref as a prop directly, so forwardRef is rarely needed. Trust inference for useCallback and useMemo. For custom hooks, use an as const tuple for two values and an object beyond that.

Next chapter #

In the next Chapter 19 Typing events and forms we cover the types for event objects and form inputs. The JavaScript patterns from Chapter 6 (Event handling) and Chapter 9 (Handling forms) are put back on top of TypeScript, and we lay down the foundation for the FormData type you will meet in Chapter 27 (Server Actions and forms).

X