TypeScript React Hooks

Med

React 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

Related Concepts