Typing Hooks
Hooks are the unit of reuse in React, and TypeScript makes them safe to share. A well-typed useReducer rejects invalid actions and narrows state inside each case; a well-typed custom hook gives every caller precise types for whatever it returns. The two recurring techniques are discriminated-union action types and getting the return shape right — an object for named values, or a tuple frozen with as const for useState-style pairs. This page covers typing useReducer, writing custom hooks, and returning tuples that keep their element types and labels.
Typing useReducer
Model the actions as a discriminated union keyed by type, then annotate the reducer’s parameters. TypeScript infers the state and dispatch types from the reducer’s signature.
import { useReducer } from "react";
interface State {
count: number;
}
type Action =
| { type: "increment"; by: number }
| { type: "reset" };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "increment":
return { count: state.count + action.by };
case "reset":
return { count: 0 };
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return <button onClick={() => dispatch({ type: "increment", by: 1 })}>{state.count}</button>;
}
Inside each case, TypeScript narrows action to that variant, so action.by exists only under "increment". At the call site dispatch({ type: "increment" }) is a compile error because by is missing.
Annotate the reducer’s return type as
State. Without it, a branch that returns the wrong shape (or forgets a field) silently widens the state type instead of erroring.
Exhaustiveness in the reducer
Give the switch a default case that assigns the action to never. If you later add an action variant but forget to handle it, the assignment fails to compile — a free reminder.
function reducer(state: State, action: Action): State {
switch (action.type) {
case "increment":
return { count: state.count + action.by };
case "reset":
return { count: 0 };
default: {
const _exhaustive: never = action;
return state;
}
}
}
Because every handled case removes a variant, by the default branch action should be never. Adding an unhandled { type: "decrement" } makes action non-never there, and // ❌ Error: Type '{...}' is not assignable to 'never' flags it.
Custom hooks returning an object
When a hook returns several named values, return an object. Callers destructure by name, order does not matter, and the inferred return type is self-documenting.
import { useState } from "react";
function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = () => setOn((prev) => !prev);
return { on, toggle }; // { on: boolean; toggle: () => void }
}
const { on, toggle } = useToggle();
The return type is inferred from the object literal, so you rarely need to annotate it. Objects are the right default for three or more values, or whenever a clear name beats positional order.
Custom hooks returning a tuple with as const
To mirror useState’s [value, setter] ergonomics, return a tuple. Plain inference widens [boolean, () => void] to the array (boolean | (() => void))[], which breaks destructuring — as const (or an explicit return annotation) locks the positions.
import { useState, useCallback } from "react";
function useCounter(start = 0) {
const [count, setCount] = useState(start);
const increment = useCallback(() => setCount((c) => c + 1), []);
return [count, increment] as const; // readonly [number, () => void]
}
const [count, increment] = useCounter(10);
increment(); // ✅ typed as () => void, not number
as const makes the return a readonly [number, () => void] tuple, so count is number and increment is the function — each position keeps its own type. Without it both would collapse to the union and you would have to cast at every call site.
| Return shape | Use when | How to type |
|---|---|---|
Object { a, b } | 3+ values, named access | inference is enough |
Tuple [a, b] | useState-like pairs | add as const |
| Single value | one result | inference is enough |
Typing the parameters and the result
For complex hooks, write an explicit return type so the public contract is fixed and errors point at the hook, not the call site. Generics let a hook adapt to whatever the caller passes in.
function useLocalStorage<T>(key: string, initial: T): [T, (value: T) => void] {
const [value, setValue] = useState<T>(() => {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : initial;
});
const update = (next: T) => {
setValue(next);
localStorage.setItem(key, JSON.stringify(next));
};
return [value, update];
}
const [theme, setTheme] = useLocalStorage("theme", "dark"); // theme: string
The generic T flows from the initial argument, so useLocalStorage("count", 0) gives a [number, (v: number) => void] tuple with no annotations at the call site.
Best Practices
- Model reducer actions as a discriminated union keyed by
type. - Annotate the reducer’s return type so wrong-shaped branches error instead of widening state.
- Add a
neverdefault case to enforce exhaustive action handling. - Return an object from custom hooks with 3+ named values; return a tuple for
useState-like pairs. - Always add
as const(or an explicit tuple return type) to hooks that return tuples. - Use generics so reusable hooks adapt to the caller’s value types.
- Wrap returned callbacks in
useCallbackso their identity is stable for dependency arrays.