TypeScript + React in Practice #3: Typing hooks
In #2 Typing props and children we saw how to define the component interface as a type. This post organizes what’s inside the component — how to handle the types of built-in hooks.
Start with one big rule:
Where inference can do it, leave it to inference; where inference falls short, be explicit.
The same rule applies to all five hooks.
useState — inferred from the initial value #
The most common pattern first.
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 also auto-typed as Dispatch<SetStateAction<number>>, so setCount(1) and setCount((c) => c + 1) both work correctly.
When the initial value is 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 — actually not what you wanted
To allow only 'idle', 'loading', 'done', specify the 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, inference is just null, which doesn’t work properly. Always specify the possible shape via the type argument.
type User = { id: string; name: string };
const [user, setUser] = useState<User | null>(null);
setUser({ id: 'u1', name: '커티스' }); // OK
setUser(null); // OK (logout)
This pattern is very common for “before data loads” states. You’ll meet it more often as form state in the next post.
Lazy initializer follows the same rule #
When the initial value is built by a function, the state type is the function’s return type.
const [todos, setTodos] = useState<Todo[]>(() => loadFromStorage());If loadFromStorage()’s return type is Todo[], you can omit the type argument. But if the return type is ambiguous or unknown, specifying it is safer.
useReducer — narrowing actions is the real value #
If useState fits simple changes, useReducer shines when gathering many kinds of changes in one place. With TypeScript, modeling actions as a discriminated union narrows them naturally inside the reducer.
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 only visible 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. - Missing required payload like
dispatch({ type: 'set' })produces a compile error. - Typos like
dispatch({ type: 'unknown' })are caught.
This safety net is virtually impossible in JavaScript. Once you start using reducers, it’s one of the places where TypeScript shines most.
Exhaustiveness check with never
#
How do you notice when you add an action and forget to handle it in the reducer? Use never in the default branch and the compiler catches it.
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; // ✗ if you forget a new action
return state;
}
}
}Add a new variant to Action and forget to handle it in the reducer, and the _exhaustive: never line lights up red. The compile step blocks the risk of a missed case.
useRef — two uses, two typing patterns #
useRef has two typing patterns based on intent.
1) DOM node ref #
For a ref attached to an element, use null as the initial value and tell it the element type via a type argument.
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’s null, so you need optional chaining (?.).
2) Holding a mutable value #
When it’s not state but you need “to hold a value and have the same object across renders” — like a setInterval ID or remembering a previous prop — also use a ref. Here, give a meaningful initial value and let inference handle it.
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, because the initial value is already meaningful.
Note: Older material often hand-wrote
MutableRefObjectto distinguish the two cases. Modern@types/reactdiscriminates by initial value automatically, so you rarely need to think about it.
Typing ref in forwardRef
#
When the parent needs to attach a ref to a child component’s DOM, you would use forwardRef. From React 19 on, you can simply receive ref like 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="이름" />;
}forwardRef itself still works, but new code looks shorter and types are more natural with the pattern above.
useCallback — preserves the signature #
useCallback is for preserving the function’s reference identity. From a typing standpoint there’s almost nothing extra to do. The function passed in keeps its type.
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 annotation needed.
We cover event types in detail in the next post (#4 Typing events and forms). For now just remember “the parameter type of an event handler function is annotated inside the handler.”
useMemo — value type passes through inference #
useMemo is the same. The type of the value built inside becomes the return type.
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. Annotation is rarely needed; if you do, give a type argument like useMemo<{ total: number; done: number }>(...).
Custom hooks — return shape: tuple vs object #
When composing built-in hooks into a custom hook, deciding the return shape often comes up. There’s no single right answer, but two guidelines help.
1) Two or three values where the consumer wants 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;
}
// Consumer names freely
const [isOpen, toggleOpen] = useToggle();as const is the trick. Without it, inference becomes (boolean | (() => void))[], mixing types when destructuring. With as const, it’s precisely the tuple [boolean, () => void].
2) Four or more values, or names that carry 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 };
}
// Consumer
const { items, add, remove } = useTodos();For most non-trivial custom hooks, the object form reads better. The names carry meaning by themselves. A reasonable rule: use a tuple only when matching the shape of built-in hooks like useState/useReducer that return two values.
Wrap-up #
This post covered:
useStateis inferred from the initial value. Specify the type argument when starting fromnullor when narrowing a union.useReducerbenefits from a discriminated union for actions. Useneverfor exhaustiveness checks.useRefhas two patterns — for DOM (start withnull) and for holding values (meaningful initial value)- React 19 lets you receive
refas a prop directly, soforwardRefis rarely needed - Trust inference for
useCallbackanduseMemo - Custom hooks: tuples for two values with
as const; object for more
In the next post (#4 Typing events and forms) we cover what types to use for event objects and form inputs, plus how to type controlled and uncontrolled forms in TypeScript.