ReactTyping Hooks

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:

TSX
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:

TSX
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:

TSX
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:

TSX
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:

TSX
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>
  )
}
Tip
Add a `never` branch to your reducer default to get exhaustiveness checking. If you add a new action type and forget to handle it, TypeScript will flag the default branch at compile time.
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:

TSX
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:

TSX
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)
Note
When a custom hook returns a tuple (multiple values as an array), always use `as const` or an explicit return type. Without it, TypeScript widens the tuple to an array, making destructuring less useful.
  • useState<T> — provide T when the initial value is null or ambiguous

  • useRef<HTMLInputElement>(null) — DOM refs always start as null

  • useRef<number>(0) — mutable values use the actual initial value

  • createContext<T | null>(null) — null init + custom hook assertion pattern

  • useReducer — discriminated union for actions enables exhaustive checking

  • useMemo / useCallback — types inferred; use as const on tuple returns