Skip to content
React rc typescript 5 min read

Typing Hooks

Hooks are where most React state lives, so typing them well is what keeps a component’s data flow honest. TypeScript can often infer hook types for you, but the moments where inference falls short — empty initial state, DOM refs, reducer actions, context with no default — are exactly where bugs hide. This page covers how useState infers and when to add a generic, the two faces of useRef, modelling reducer state and action unions, and building a context that is safe to consume.

Typing useState

For most state, you write nothing extra. useState infers the type of the state from the initial value you pass, and the setter is typed to match.

import { useState } from "react";

function Counter() {
  const [count, setCount] = useState(0); // count: number
  return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}

Inference breaks down when the initial value does not represent the full set of possible values. If state starts as null but will later hold an object, or starts as an empty array, you must supply an explicit generic so TypeScript knows the eventual shape.

import { useState } from "react";

interface User {
  id: number;
  name: string;
}

function Profile() {
  const [user, setUser] = useState<User | null>(null);
  const [tags, setTags] = useState<string[]>([]);

  return user ? <h1>{user.name}</h1> : <p>Loading…</p>;
}

Without <User | null>, the state would be inferred as null and setUser would reject any real user object. The string[] annotation prevents the array from being inferred as never[].

Prefer a literal union over a loose string for finite state. useState<"idle" | "loading" | "error">("idle") makes invalid status values a compile error.

Typing useRef

useRef has two distinct uses, and they need different annotations. For a DOM ref that you attach to an element, initialise with null and pass the element type. The current property is then T | null, because the ref is null before the element mounts.

import { useRef } from "react";

function SearchBox() {
  const inputRef = useRef<HTMLInputElement>(null);

  const focus = () => inputRef.current?.focus();

  return (
    <>
      <input ref={inputRef} />
      <button onClick={focus}>Focus</button>
    </>
  );
}

For a mutable instance variable — a value you read and write across renders without triggering re-renders — give it a real initial value. TypeScript then types current as the value type (not nullable), so you can assign to it freely.

import { useEffect, useRef } from "react";

function Timer() {
  const intervalId = useRef<number>(0);

  useEffect(() => {
    intervalId.current = window.setInterval(() => console.log("tick"), 1000);
    return () => clearInterval(intervalId.current);
  }, []);

  return <p>Running…</p>;
}
UsageInitial valueType of currentWritable?
DOM refnullHTMLInputElement | nullNo (React assigns it)
Mutable valuereal valuethe value typeYes

Typing useReducer

A reducer benefits hugely from types: a state interface plus a discriminated union of actions gives you exhaustive, autocompleted action handling. Define both types, then let useReducer infer the rest from the reducer function’s signature.

import { useReducer } from "react";

interface State {
  count: number;
  step: number;
}

type Action =
  | { type: "increment" }
  | { type: "decrement" }
  | { type: "setStep"; step: number };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "increment":
      return { ...state, count: state.count + state.step };
    case "decrement":
      return { ...state, count: state.count - state.step };
    case "setStep":
      return { ...state, step: action.step };
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, { count: 0, step: 1 });

  return (
    <>
      <output>{state.count}</output>
      <button onClick={() => dispatch({ type: "increment" })}>+{state.step}</button>
    </>
  );
}

Because Action is a discriminated union on type, the switch narrows each case, so action.step is only accessible inside "setStep". Dispatching { type: "setStep" } without step, or an unknown type, is a compile error.

Typing context

Context is the classic place where a default value lies about the real shape. The robust pattern is to type the context value, default it to undefined, and wrap useContext in a custom hook that throws if used outside a provider — so consumers never have to null-check.

import { createContext, useContext, useState, ReactNode } from "react";

interface ThemeContextValue {
  theme: "light" | "dark";
  toggle: () => void;
}

const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<"light" | "dark">("light");
  const toggle = () => setTheme((t) => (t === "light" ? "dark" : "light"));

  return (
    <ThemeContext.Provider value={{ theme, toggle }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error("useTheme must be used within a ThemeProvider");
  return ctx; // narrowed to ThemeContextValue, never undefined
}

The early throw narrows the return type from ThemeContextValue | undefined to ThemeContextValue, so every component that calls useTheme() gets a fully typed, non-nullable value.

Output:

Uncaught Error: useTheme must be used within a ThemeProvider

That error appears only when a component forgets the provider — a clear, immediate signal instead of a silent undefined.

Best Practices

  • Let useState infer from the initial value; add a generic only when the initial value is null, [], or otherwise narrower than reality.
  • Use literal unions for finite state instead of string or number.
  • Initialise DOM refs with useRef<Element>(null) and use optional chaining on .current.
  • Give mutable refs a real initial value so current is non-nullable.
  • Model reducer actions as a discriminated union keyed on type for exhaustive narrowing.
  • Default context to undefined and guard access in a custom hook that throws.
  • Export the custom context hook, not the raw context, so consumers get a typed, safe API.
Last updated June 14, 2026
Was this helpful?