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.
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.
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.
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.
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.
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.
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.valueis visible only in theaction.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.
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.
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.
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.
| Use | Initial value | .current type | null check |
|---|---|---|---|
| DOM node | null | T | null | needed (?.) |
| value box | a meaningful value | the type of that value | not 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.
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.
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.
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
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
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:
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:
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 #
- Add one more
Action({ type: 'rename'; id: string; text: string }) touseTodoReducerabove and handle it in the reducer so the build passes again. Get the feel of how the exhaustiveness check catches missing cases. - Make the
Statusunion ofuseState<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 theneverpattern). - 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 useuseRef<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.currenttypes differ (HTMLInputElement | nullvsnumber).
In one line:
useStateinfers from the initial value. Declare a type argument when starting withnullor when narrowing a union. WithuseReducer, catch actions as a discriminated union and useneverfor an exhaustiveness check.useRefhas two patterns: a DOM one (starts atnull) and a value box (a meaningful initial value). In React 19 you can takerefas a prop directly, soforwardRefis rarely needed. Trust inference foruseCallbackanduseMemo. For custom hooks, use anas consttuple 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).