TypeScript React Hooks
MedReact hooks have excellent TypeScript support, but some patterns require explicit type annotations. useState needs type parameters for complex state, useRef needs to distinguish between mutable and immutable refs, and useReducer needs properly typed actions. Custom hooks that return tuples need `as const` to preserve the tuple type.
Interactive Visualization
Key Points
- `useState<T>` accepts a type parameter for complex or union state types
- `useRef<HTMLElement>(null)` creates an immutable ref; `useRef<number>(0)` creates a mutable ref
- `useReducer` needs typed state and discriminated union actions
- Custom hooks returning tuples should use `as const` to avoid widening to arrays
- `useCallback` and `useMemo` infer types from the callback/factory function
- `useContext` returns `T | undefined` when the provider may be missing
- Generic custom hooks use type parameters for reusable, type-safe hook logic
Code Examples
useState & useRef
// useState with explicit type for unions/complex state const [user, setUser] = useState<User | null>(null) const [status, setStatus] = useState<'idle' | 'loading' | 'error'>('idle') // useState infers from initial value when possible const [count, setCount] = useState(0) // inferred as number // useRef for DOM elements — immutable ref (null initial) const inputRef = useRef<HTMLInputElement>(null) // useRef for mutable values — no null in type parameter const timerRef = useRef<number>(0) timerRef.current = window.setTimeout(() => {}, 1000) // Cleanup pattern useEffect(() => { return () => clearTimeout(timerRef.current) }, []) interface User { name: string email: string }
useState needs explicit types when the initial value does not represent all possible states (e.g., starting as null but later holding an object). useRef distinguishes DOM refs (immutable .current) from value refs (mutable .current).
useReducer with Typed Actions
interface TodoState { todos: Todo[] filter: 'all' | 'active' | 'completed' } // Discriminated union for actions type TodoAction = | { type: 'ADD'; payload: { text: string } } | { type: 'TOGGLE'; payload: { id: string } } | { type: 'DELETE'; payload: { id: string } } | { type: 'SET_FILTER'; payload: { filter: TodoState['filter'] } } function todoReducer(state: TodoState, action: TodoAction): TodoState { switch (action.type) { case 'ADD': return { ...state, todos: [...state.todos, { id: crypto.randomUUID(), text: action.payload.text, completed: false, }], } case 'TOGGLE': return { ...state, todos: state.todos.map(t => t.id === action.payload.id ? { ...t, completed: !t.completed } : t ), } case 'DELETE': return { ...state, todos: state.todos.filter(t => t.id !== action.payload.id), } case 'SET_FILTER': return { ...state, filter: action.payload.filter } } } const [state, dispatch] = useReducer(todoReducer, { todos: [], filter: 'all', }) // dispatch is fully typed — only valid actions allowed dispatch({ type: 'ADD', payload: { text: 'Learn TypeScript' } }) interface Todo { id: string; text: string; completed: boolean }
useReducer benefits enormously from TypeScript. Discriminated union actions ensure dispatch only accepts valid action shapes, and the reducer must handle every case.
Custom Hooks with Generics
// Generic data fetching hook function useFetch<T>(url: string): { data: T | null loading: boolean error: Error | null } { const [data, setData] = useState<T | null>(null) const [loading, setLoading] = useState(true) const [error, setError] = useState<Error | null>(null) useEffect(() => { let cancelled = false fetch(url) .then(r => r.json() as Promise<T>) .then(d => { if (!cancelled) { setData(d); setLoading(false) } }) .catch(e => { if (!cancelled) { setError(e as Error); setLoading(false) } }) return () => { cancelled = true } }, [url]) return { data, loading, error } } // Usage — type parameter specifies the response shape const { data, loading } = useFetch<User[]>('/api/users') // data is User[] | null // Tuple return with as const function useToggle(initial = false) { const [on, setOn] = useState(initial) const toggle = useCallback(() => setOn(v => !v), []) return [on, toggle] as const // preserves [boolean, () => void] } const [isOpen, toggleOpen] = useToggle() // Without as const: (boolean | (() => void))[] — unusable! interface User { name: string; email: string }
Generic custom hooks accept type parameters to type their return data. The `as const` assertion is critical for tuple returns — without it, TypeScript widens the type to a union array.
Common Mistakes
- Not providing a type parameter to useState when the initial value is null
- Using `useRef<HTMLInputElement>(null)` but trying to assign to `.current` (it is readonly for DOM refs)
- Forgetting `as const` on custom hook tuple returns, getting a widened array type
- Not typing useReducer actions as discriminated unions, losing exhaustive checking
- Using `any` in custom hooks instead of generic type parameters
Interview Tips
- Know the mutable vs immutable ref distinction and when each applies
- Be ready to type a useReducer with discriminated union actions
- Explain the `as const` trick for custom hooks that return tuples
- Mention generic hooks as a pattern for reusable data fetching or form logic