Typing React Hooks Safely with TypeScript
AI generated
<T>
type
TypeScript · React · Hooks · Type Safety
Typing React Hooks Safely with TypeScript
From useState through useReducer to your own custom hook

React Hooks work even without explicit types, but usually only correctly by accident. As soon as a state can take multiple possible shapes, a ref gets used in three different ways, or a custom hook loads data from an API, typing decides whether errors surface at compile time or only for the user. This article shows how to make React Hooks type-safe from the ground up with TypeScript, from useState to your own generic custom hook.

14 min read useState · useRef · useReducer · Custom Hooks TypeScript 5.x · React 18/19

1. Why hooks need explicit types

React Hooks are ordinary functions, and as with any function, the quality of type checking depends on how precisely parameters and return values are described. In simple cases like useState(0), TypeScript infers the type automatically and correctly, with no explicit annotation needed. But once state gets more complex, for example an object that starts out as null and later takes a concrete shape, automatic inference is no longer enough to keep React Hooks reliably type-safe.

A second reason to deliberately type hooks concerns the kinds of bugs they typically cause. A useRef without a correct type allows access to properties that do not actually exist at runtime. A useReducer with loose action objects lets typos in the type field slip through unnoticed. A custom hook without a generic type parameter forces every call site into manual type conversions. All of these problems disappear once the underlying types of the hooks are deliberately and explicitly defined instead of relying on accidental inference.

The third aspect is reusability: a type-safe custom hook can be adopted into other parts of the application without friction, because its contract, meaning input and return type, is documented independent of the specific calling context. Without this explicit typing, every custom hook stays effectively tied to the context in which it originated.

2. useState: type inference, union states and object state

useState reliably infers the state type automatically for primitive initial values like numbers, strings, or booleans. Things get problematic once the initial value is null or undefined, a pattern that is common for asynchronously loaded data: without an explicit type argument, TypeScript incorrectly infers the state type as plain null, causing every later assignment of a real value to trigger a compile error. The fix is an explicit type argument, for example useState<User | null>(null), telling the compiler from the start which shapes the state can take over its whole lifetime.

For states with several clearly separated phases, for example loading, success, and error, a single state modeled as a union type pays off instead of several separate useState calls. This modeling prevents contradictory intermediate states from creeping in, for example isLoading: true together with an already set error object at the same time, which can easily happen with several independent boolean flags but is structurally excluded with a single discriminated union.


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

// Without an explicit type argument, TypeScript infers "null" only
const [user, setUser] = useState<User | null>(null);

// Later assignment is now valid and fully typed
setUser({ id: 1, email: "dev@mironsoft.de" });

// Union state instead of multiple independent booleans
type RequestState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: User }
  | { status: "error"; message: string };

const [requestState, setRequestState] = useState<RequestState>({ status: "idle" });

// Narrowing works exactly like with any other discriminated union
if (requestState.status === "success") {
  console.log(requestState.data.email); // fully typed, no optional chaining needed
}

3. useRef: three use cases, three different types

useRef is used in practice for three fundamentally different purposes, and each one calls for its own typing. For accessing a DOM element, for example to programmatically set focus, useRef<HTMLInputElement>(null) is the right choice. The initial value null is mandatory here, because React only fills the ref with the actual element after the first render, and accessing it via ref.current therefore consistently requires a null check or the optional chaining operator.

For mutable values that should persist across renders without triggering a re-render, for example a timer ID or a counter of past calls, the type without null as the initial value is the correct form: useRef<number>(0). In this case, ref.current is never null, which is why TypeScript does not enforce a null check here either. If useRef is instead called with no type argument and only null as the initial value, TypeScript automatically infers MutableRefObject<null>, which fixes ref.current to null permanently and is too narrow for practically any use case.


import { useRef, useEffect } from "react";

function SearchInput() {
  // DOM ref: initial value must be null, access requires a null check
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    inputRef.current?.focus();
  }, []);

  return <input ref={inputRef} type="text" />;
}

function PollingWidget() {
  // Mutable value ref: never null, no null check needed on access
  const intervalIdRef = useRef<number>(0);
  const callCountRef = useRef<number>(0);

  useEffect(() => {
    intervalIdRef.current = window.setInterval(() => {
      callCountRef.current += 1;
    }, 1000);

    return () => window.clearInterval(intervalIdRef.current);
  }, []);

  return null;
}

4. Keeping useEffect and dependency arrays type-safe

useEffect itself rarely needs explicit typing, because TypeScript automatically and correctly infers the effect function and the returned cleanup function. The real gain in type safety here comes less from type parameters and more from strict linting: the eslint-plugin-react-hooks plugin checks whether every variable used in the effect actually appears in the dependency array, preventing the most common bug type with useEffect, namely a stale value (stale closure) resulting from an incomplete dependency array.

On the type side, it still pays off to keep the return value of useEffect deliberately consistent: either the effect returns a cleanup function of type () => void, or it implicitly returns undefined, but never any other value. TypeScript already rejects effect functions that accidentally return a promise, for example due to a forgotten async placed directly on the effect function, at compile time, a common mistake on first contact with asynchronous code inside useEffect.


import { useEffect, useState } from "react";

function ProductDetail({ productId }: { productId: number }) {
  const [product, setProduct] = useState<{ name: string } | null>(null);

  useEffect(() => {
    // Compile error if this arrow function itself were declared "async":
    // useEffect callbacks must return void or a cleanup function, not a Promise
    let cancelled = false;

    async function load() {
      const response = await fetch(`/api/products/${productId}`);
      const data = await response.json();
      if (!cancelled) setProduct(data);
    }

    load();

    return () => {
      cancelled = true; // cleanup function: return type is () => void
    };
  }, [productId]); // eslint-plugin-react-hooks flags a missing productId here

  return <p>{product?.name ?? "Loading..."}</p>;
}

5. useReducer with discriminated union actions

Once a state has several related fields that are updated by different events, for example a form with multiple fields, validation errors, and a submit status, useReducer becomes noticeably more maintainable than several useState calls. Type safety here stands or falls with how the actions are typed: a loose { type: string; payload: any } allows arbitrary typos in the type field and effectively leaves payload unchecked. The correct modeling is a discriminated union of all possible actions, where each variant carries its own, specific payload field.

Inside each case branch of the switch, the reducer itself then automatically gets the correctly narrowed action type, including the matching payload. A default branch that treats action as never additionally surfaces when a new action variant gets added but is not yet handled in the reducer: the compiler then reports an error, because never accepts no value other than itself.


interface FormState {
  email: string;
  password: string;
  error: string | null;
  isSubmitting: boolean;
}

type FormAction =
  | { type: "SET_EMAIL"; payload: string }
  | { type: "SET_PASSWORD"; payload: string }
  | { type: "SUBMIT_START" }
  | { type: "SUBMIT_ERROR"; payload: string }
  | { type: "SUBMIT_SUCCESS" };

function formReducer(state: FormState, action: FormAction): FormState {
  switch (action.type) {
    case "SET_EMAIL":
      return { ...state, email: action.payload };
    case "SET_PASSWORD":
      return { ...state, password: action.payload };
    case "SUBMIT_START":
      return { ...state, isSubmitting: true, error: null };
    case "SUBMIT_ERROR":
      return { ...state, isSubmitting: false, error: action.payload };
    case "SUBMIT_SUCCESS":
      return { ...state, isSubmitting: false };
    default:
      // Exhaustiveness check: compile error if a new action variant is unhandled
      return ((): never => { throw new Error(`Unhandled action`); })();
  }
}

6. useMemo and useCallback: generics and return types

useMemo and useCallback are both generic, but usually infer their type parameter automatically from the supplied callback, so an explicit type annotation is rarely needed. More important than the typing itself with these two hooks is the correct dependency array: a useCallback with a function that references a prop but does not list that prop in the dependency array produces a closure over a stale value, a bug that TypeScript alone does not catch but eslint-plugin-react-hooks reliably reports.

A special case that genuinely requires explicit typing is a useMemo whose return value should be of a more complex, not directly inferable type, for example a computed map structure. Here an explicit type annotation on the target variable helps more than a type argument on the hook itself, because TypeScript already correctly infers the return type of the supplied function, and the annotation really serves readability at the call site.


import { useMemo, useCallback } from "react";

interface Product { id: number; category: string; price: number; }

function ProductBoard({ products, onSelect }: { products: Product[]; onSelect: (id: number) => void }) {
  // Return type is inferred as Map<string, Product[]>, no explicit argument needed
  const byCategory = useMemo(() => {
    const map = new Map<string, Product[]>();
    for (const product of products) {
      const list = map.get(product.category) ?? [];
      list.push(product);
      map.set(product.category, list);
    }
    return map;
  }, [products]);

  // Type of handleSelect is inferred as (id: number) => void
  const handleSelect = useCallback(
    (id: number) => {
      onSelect(id);
    },
    [onSelect] // eslint-plugin-react-hooks flags this if omitted
  );

  return null;
}

7. Writing your own custom hooks with generics

A custom hook is ultimately an ordinary function that calls other hooks, and therefore benefits from generic type parameters just as much as any other reusable function. A common example is a useLocalStorage hook that keeps a value in sync with browser storage: the stored value can in principle be any serializable type, which is why a type parameter T is the only sensible solution instead of writing separate hooks for strings, numbers, and objects.

It matters to type the return value of a custom hook as a tuple with a fixed order when it follows the useState pattern, so destructuring at the call site gets the correct, specific types instead of an overly broad union type. Without explicit tuple typing, TypeScript would sometimes infer a returned array as (T | Function)[], which strips destructuring of its precision.


import { useState, useCallback } from "react";

// Explicit tuple return type keeps destructuring precise at the call site
function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? (JSON.parse(item) as T) : initialValue;
    } catch {
      return initialValue;
    }
  });

  const setValue = useCallback((value: T) => {
    setStoredValue(value);
    window.localStorage.setItem(key, JSON.stringify(value));
  }, [key]);

  return [storedValue, setValue];
}

interface Preferences { theme: "light" | "dark"; language: string; }

// T is inferred as Preferences from the initial value
const [prefs, setPrefs] = useLocalStorage<Preferences>("prefs", { theme: "light", language: "en" });

8. Async custom hooks: typing loading, data, and error

A particularly common custom hook loads data from an API and needs to represent three states: the loading process, the successfully loaded data, and a possible error. Instead of three independent fields that would allow invalid combinations, this too is modeled as a single union state, exactly as described in the useState section, made generic over the type of the loaded data so the same hook stays reusable for products, users, or any other resource.

The generic type parameter T is either given explicitly when the hook is called or inferred from the return type of the supplied loader function. Inside the hook itself, every state change stays bound to exactly one of the three possible shapes, so a caller can never access both the loading status and the data at the same time without having explicitly checked status first.


import { useState, useEffect } from "react";

type AsyncState<T> =
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; message: string };

// Generic custom hook: T is the shape of the loaded resource
function useAsyncResource<T>(loader: () => Promise<T>, deps: unknown[]): AsyncState<T> {
  const [state, setState] = useState<AsyncState<T>>({ status: "loading" });

  useEffect(() => {
    let cancelled = false;
    setState({ status: "loading" });

    loader()
      .then((data) => { if (!cancelled) setState({ status: "success", data }); })
      .catch((err) => { if (!cancelled) setState({ status: "error", message: String(err) }); });

    return () => { cancelled = true; };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps);

  return state;
}

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

function useProduct(id: number) {
  return useAsyncResource<Product>(
    () => fetch(`/api/products/${id}`).then((r) => r.json()),
    [id]
  );
}

9. Hook typing patterns compared

The following overview summarizes which typing pattern fits which hook and which scenario.

Hook / scenario Unsuitable approach Recommended pattern Benefit
useState with initial null no type argument useState<User | null>(null) A later real assignment stays valid
Referencing a DOM element useRef(null) untyped useRef<HTMLInputElement>(null) Correct methods on the element autocomplete
Several related state fields several independent useState calls useReducer with a discriminated union Invalid state combinations are impossible
Reusable storage hook separate hook per data type generic custom hook with T One implementation for every type
Loading/success/error state three independent booleans generic union state AsyncState<T> No simultaneous access to inconsistent fields

The common thread across every recommended pattern: states that mutually exclude each other are modeled as a union instead of independent fields, and reusable logic is made generic instead of duplicated for every concrete type. Together, both make React Hooks robust against exactly the bugs that occur most often in practice: inconsistent intermediate states and silently mistyped refs or return values.

Mironsoft

TypeScript tooling, type-safe React hooks and Magento/Hyvä integrations

React Hooks that rely on the compiler instead of convention?

We review existing hooks for loose state, unsafe refs, and missing discriminated unions, and build type-safe custom hooks that reuse generically across your entire frontend.

Hooks audit

Reviewing existing hooks for loose types and race conditions

Custom hook library

Generic, type-safe hooks for data loading, storage, and forms

Training

Team workshop on type-safe hooks patterns

10. Summary

Typing React Hooks safely mostly means consistently modeling mutually exclusive states as a discriminated union instead of independent fields, whether in useState, useReducer, or an asynchronously loading custom hook. useRef needs three different type forms depending on the use case: with null for DOM elements, without null for mutable values that persist across renders. useEffect itself rarely needs explicit types, but benefits massively from strict ESLint linting for the dependency array.

Custom hooks only become genuinely reusable through generic type parameters, combined with explicit tuple typing of the return value when they follow the useState pattern. Anyone who applies these principles consistently to every React Hook in the project gets a codebase where state bugs and stale refs surface already at compile time instead of only after deployment, in front of the user.

Typing React Hooks Safely - The Essentials at a Glance

useState & useReducer

Explicit type argument with an initial null, discriminated union instead of independent fields for related state.

useRef

With null as the initial value for DOM elements, without null for mutable values that persist across renders.

useEffect

Return function consistently as () => void, use an ESLint plugin for complete dependency arrays.

Custom hooks

Generic type parameter for reusability, explicit tuple typing of the return value.

11. FAQ: Typing React Hooks Safely

1Typing useState with an initial null?
With an explicit type argument, e.g. useState(null), otherwise TypeScript only infers plain null.
2Why useRef for DOM elements with null?
React fills the ref only after the first render. null reflects that correctly and forces a null check on access.
3useRef with or without null as the type?
With null for DOM elements, without null for mutable values that persist across renders.
4Preventing stale closures in useEffect?
With eslint-plugin-react-hooks, which reliably flags missing dependency array entries.
5useReducer instead of several useState?
With related fields, a discriminated union of actions prevents invalid state combinations.
6What does never in a default branch do?
Checks exhaustiveness: unhandled new action variants trigger a compile error.
7Type argument needed for useMemo/useCallback?
Usually not, both infer the type automatically. More important is a complete dependency array.
8Making a custom hook generic?
With a type parameter T on the function, type the return value as a tuple when following the useState pattern.
9Typing an async custom hook?
With a generic union state AsyncState for loading, success, and error.
10Why is type: string; payload: any problematic?
Typos in the type field slip through unnoticed, payload stays unchecked. A discriminated union prevents both.