Typing Hooks

useState and useRef

"use client";
import { useEffect, useRef, useState } from "react";

type User = { id: string; name: string };

export function Profile() {
  const [count, setCount] = useState(0);                 // inferred: number
  const [user, setUser] = useState<User | null>(null);   // needs a type: starts as null
  const [tags, setTags] = useState<string[]>([]);        // empty array needs a type

  const inputRef = useRef<HTMLInputElement>(null);       // DOM ref: RefObject<HTMLInputElement | null>
  const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined); // mutable value

  useEffect(() => {
    inputRef.current?.focus();
    timerRef.current = setTimeout(() => setCount((c) => c + 1), 1000);
    return () => clearTimeout(timerRef.current);
  }, []);

  return <input ref={inputRef} defaultValue={user?.name ?? ""} data-tags={tags.length} />;
}

useReducer With a Discriminated Union

Typing actions as a discriminated union gives the reducer exhaustive, narrowed cases. The reducer itself is plain TypeScript, so it can be run and tested without React:

Example

type CartItem = { sku: string; name: string; price: number; qty: number };
type CartState = { items: CartItem[]; coupon: string | null };
type CartAction =
  | { type: "add"; item: Omit<CartItem, "qty"> }
  | { type: "setQty"; sku: string; qty: number }
  | { type: "remove"; sku: string }
  | { type: "applyCoupon"; code: string };

function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case "add": {
      const found = state.items.find((i) => i.sku === action.item.sku);
      return found
        ? { ...state, items: state.items.map((i) => (i.sku === action.item.sku ? { ...i, qty: i.qty + 1 } : i)) }
        : { ...state, items: [...state.items, { ...action.item, qty: 1 }] };
    }
    case "setQty":
      return { ...state, items: state.items.map((i) => (i.sku === action.sku ? { ...i, qty: action.qty } : i)) };
    case "remove":
      return { ...state, items: state.items.filter((i) => i.sku !== action.sku) };
    case "applyCoupon":
      return { ...state, coupon: action.code.toUpperCase() };
  }
}

const actions: CartAction[] = [
  { type: "add", item: { sku: "A", name: "Kettle", price: 1299 } },
  { type: "add", item: { sku: "B", name: "Mug", price: 199 } },
  { type: "add", item: { sku: "B", name: "Mug", price: 199 } },
  { type: "setQty", sku: "A", qty: 2 },
  { type: "remove", sku: "B" },
  { type: "applyCoupon", code: "fest20" },
];
const final = actions.reduce(cartReducer, { items: [], coupon: null });
console.log(final.items.map((i) => `${i.name} x${i.qty}`), final.coupon);

Output

[ 'Kettle x2' ] FEST20
// in the component
const [cart, dispatch] = useReducer(cartReducer, { items: [], coupon: null });
dispatch({ type: "setQty", sku: "A", qty: 2 });   // OK
dispatch({ type: "setQty", sku: "A" });           // error: qty missing