Typing Hooks
Every built-in React hook accepts type parameters that make your data contracts explicit. Some types are inferred automatically; others require annotation to avoid falling back to any or overly wide types. This page covers the hooks you use most, with practical typed examples for each.
useState — Explicit vs Inferred
TypeScript infers useState types from the initial value in most cases. Provide an explicit type parameter when the initial value is ambiguous or does not fully represent the type you need:
import { useState } from 'react'
// Inferred — initial value gives TypeScript enough information
const [count, setCount] = useState(0) // number
const [label, setLabel] = useState('hello') // string
const [active, setActive] = useState(false) // boolean
// Must be explicit — null doesn't describe the full type
interface Product {
id: string
name: string
price: number
}
const [product, setProduct] = useState<Product | null>(null)
// After a fetch, TypeScript enforces the full shape:
setProduct({ id: '1', name: 'Keyboard', price: 99 })
// ✗ Error: 'stock' does not exist on type 'Product'
setProduct({ id: '2', name: 'Mouse', price: 49, stock: 100 })useRef — DOM Refs vs Mutable Values
useRef has two distinct use cases, and the typing differs between them. For DOM refs you must initialize with null and use the element's exact type:
import { useRef, useEffect } from 'react'
// DOM ref — always initialize with null
// The type argument is the DOM element type, NOT null
function TextInput() {
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
// ref.current is HTMLInputElement | null — must check before use
inputRef.current?.focus()
}, [])
return <input ref={inputRef} type="text" />
}
// Common DOM element types:
// HTMLInputElement for <input>
// HTMLButtonElement for <button>
// HTMLDivElement for <div>
// HTMLFormElement for <form>
// HTMLTextAreaElement for <textarea>
// SVGSVGElement for <svg>For mutable values that do not trigger re-renders (timers, previous values, counters), initialize with the actual starting value and null is not needed:
import { useRef, useEffect } from 'react'
function Timer() {
// Mutable ref — stores a timer ID, not a DOM element
const timerId = useRef<number>(0)
useEffect(() => {
timerId.current = window.setInterval(() => {
console.log('tick')
}, 1000)
return () => {
clearInterval(timerId.current)
}
}, [])
return <div>Running timer</div>
}useContext — Typed Context with a Custom Hook
createContext needs a type parameter. The safest pattern initializes the context with null and uses a custom hook with an assertion to guarantee the context is consumed inside its provider:
import { createContext, useContext, useState, ReactNode } from 'react'
interface ThemeContextType {
theme: 'light' | 'dark'
toggleTheme: () => void
}
// Initialize with null — the custom hook below handles the null case
const ThemeContext = createContext<ThemeContextType | null>(null)
// Custom hook — throws a clear error instead of silent undefined
export function useTheme(): ThemeContextType {
const ctx = useContext(ThemeContext)
if (!ctx) {
throw new Error('useTheme must be used inside <ThemeProvider>')
}
return ctx
}
// Provider component
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light')
const toggleTheme = () => setTheme(t => (t === 'light' ? 'dark' : 'light'))
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
)
}
// Consumer — fully typed, no assertion needed
function Toolbar() {
const { theme, toggleTheme } = useTheme() // ThemeContextType is guaranteed
return <button onClick={toggleTheme}>Mode: {theme}</button>
}useReducer — Discriminated Union Actions
useReducer shines when paired with a discriminated union for the action type. TypeScript narrows the action inside each case branch, giving you type-safe access to the payload:
import { useReducer } from 'react'
interface CounterState {
count: number
step: number
}
// Discriminated union — 'type' is the discriminant
type CounterAction =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'reset' }
| { type: 'setStep'; payload: number } // only this action has a payload
function reducer(state: CounterState, action: CounterAction): CounterState {
switch (action.type) {
case 'increment':
return { ...state, count: state.count + state.step }
case 'decrement':
return { ...state, count: state.count - state.step }
case 'reset':
return { count: 0, step: state.step }
case 'setStep':
// TypeScript knows action.payload exists here (number)
return { ...state, step: action.payload }
default:
// Exhaustiveness check — TypeScript errors if a case is missing
return state
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0, step: 1 })
return (
<div>
<p>Count: {state.count} (step: {state.step})</p>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
<button onClick={() => dispatch({ type: 'setStep', payload: 5 })}>Step 5</button>
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
</div>
)
}useMemo and useCallback
The return type of useMemo is inferred from the factory function, so you rarely need an explicit type parameter. useCallback similarly infers its type from the function you pass:
import { useMemo, useCallback, useState } from 'react'
interface Item {
id: number
name: string
active: boolean
}
function ItemList({ items }: { items: Item[] }) {
const [filter, setFilter] = useState('')
// Return type inferred as Item[]
const filtered = useMemo(
() => items.filter(item => item.name.includes(filter)),
[items, filter]
)
// Return type inferred as (id: number) => void
const handleToggle = useCallback((id: number) => {
console.log('toggled', id)
}, [])
// You can be explicit when needed — e.g. the inferred type is too wide:
const sorted = useMemo<Item[]>(
() => [...items].sort((a, b) => a.name.localeCompare(b.name)),
[items]
)
return (
<ul>
{filtered.map(item => (
<li key={item.id} onClick={() => handleToggle(item.id)}>
{item.name}
</li>
))}
</ul>
)
}Custom Hook Return Types
TypeScript infers return types from custom hooks automatically, but being explicit helps when the returned tuple might be inferred as a wide array type:
import { useState, useCallback } from 'react'
// Without explicit return type — TypeScript infers (boolean | () => void)[]
// which is too wide: the second element could be boolean!
function useToggle(initial = false) {
const [on, setOn] = useState(initial)
const toggle = useCallback(() => setOn(v => !v), [])
return [on, toggle] as const // 'as const' narrows to [boolean, () => void]
}
// Or declare the return type explicitly — clearest option
function useCounter(initial = 0): [number, () => void, () => void] {
const [count, setCount] = useState(initial)
const inc = () => setCount(c => c + 1)
const dec = () => setCount(c => c - 1)
return [count, inc, dec]
}
// Usage — types are precise
const [isOpen, toggle] = useToggle()
const [count, increment, decrement] = useCounter(10)useState<T>— provide T when the initial value is null or ambiguoususeRef<HTMLInputElement>(null)— DOM refs always start as nulluseRef<number>(0)— mutable values use the actual initial valuecreateContext<T | null>(null)— null init + custom hook assertion patternuseReducer— discriminated union for actions enables exhaustive checkinguseMemo/useCallback— types inferred; useas conston tuple returns