Type-Safe Context and Strongly Typed Custom Hooks

React Context is the escape hatch for dependency injection across the tree: themes, auth sessions, feature flags, router-adjacent state. Left untyped—or typed dishonestly—it becomes a magnet for undefined checks, silent defaults, and hooks that lie about their guarantees. Type-safe context means the hook’s return type matches reality, and misuse (calling outside a provider) fails loudly, not downstream in a random child with a confusing null dereference.

Custom hooks are the other half of the story. They package imperative browser APIs, storage, and async workflows. If generics and discriminated unions stop at components, you still leak casts at the boundary where state is created. Strongly typed hooks keep T consistent from persistence or network through to JSX.

The undefined default trap

A pattern that still appears in tutorials:

const ThemeContext = createContext<Theme>(undefined!);

The non-null assertion tells TypeScript “trust me,” while runtime says “sometimes this really is undefined.” Consumers of useContext(ThemeContext) believe they always have a Theme; they do not. The bug surfaces when someone renders a subtree without a provider, or when tests mount a component in isolation.

Better: model absence honestly with null and narrow in a dedicated hook:

interface ThemeContextValue {
  theme: Theme
  setTheme: (theme: Theme) => void
}

const ThemeContext = createContext<ThemeContextValue | null>(null)

export function useTheme(): ThemeContextValue {
  const ctx = useContext(ThemeContext)
  if (!ctx) {
    throw new Error("useTheme must be used within ThemeProvider")
  }
  return ctx
}

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>("light")
  const value = useMemo(() => ({ theme, setTheme }), [theme])
  return (
    <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
  )
}

The “why” is operational: fail at the boundary where the contract is broken, with an error message that names the hook and the provider. Optional chaining sprinkled across every consumer is replaced by one check in useTheme.

For consumers that truly might run outside a provider (rare), expose a second hook such as useOptionalTheme(): ThemeContextValue | null instead of overloading one hook with ambiguous semantics.

Splitting context to avoid unnecessary rerenders

Typing often improves when you split volatile and stable values. A single context holding { user, setUser, theme, setTheme } forces broad rerenders and wide hook surfaces. Multiple contexts—each with its own provider and typed hook—keep types smaller and dependencies clearer. The pattern is the same: null default, throw in the hook, useMemo the value object.

Generic useLocalStorage

Local storage deals in JSON and string; your app deals in domain types. A generic hook bridges the gap while preserving T:

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) => {
      try {
        setStoredValue(value);
        window.localStorage.setItem(key, JSON.stringify(value));
      } catch {
        setStoredValue(value);
      }
    },
    [key],
  );

  return [storedValue, setValue];
}

Callers get [boolean, Dispatch] or [UserPreferences, ...] based on initialValue and explicit annotations. The remaining weakness is serialization honesty: JSON.parse cannot validate shapes at runtime. For untrusted storage, pair this with a schema validator (Zod, Valibot) and narrow to T after parse—types alone cannot prove disk contents.

useAsync with discriminated union state

Async hooks benefit from the same status tagging you use in UI state. A generic useAsync can expose:

type AsyncStatus<T> =
  | { status: "idle" }
  | { status: "pending" }
  | { status: "resolved"; data: T }
  | { status: "rejected"; error: Error };

function useAsync<T>(fn: () => Promise<T>): {
  state: AsyncStatus<T>;
  run: () => void;
  reset: () => void;
} {
  const [state, setState] = useState<AsyncStatus<T>>({ status: "idle" });

  const run = useCallback(() => {
    setState({ status: "pending" });
    fn()
      .then((data) => setState({ status: "resolved", data }))
      .catch((error: unknown) =>
        setState({
          status: "rejected",
          error: error instanceof Error ? error : new Error(String(error)),
        }),
      );
  }, [fn]);

  const reset = useCallback(() => setState({ status: "idle" }), []);

  return { state, run, reset };
}

Consumers switch on state.status and access data or error only when safe—mirroring the discriminated union lessons from component props.

useEventListener with correct DOM typings

Wrapping addEventListener is a common hook; typing it well means tying event type to element type:

function useEventListener<K extends keyof WindowEventMap>(
  target: Window,
  type: K,
  listener: (ev: WindowEventMap[K]) => void,
  options?: boolean | AddEventListenerOptions,
): void;

function useEventListener<T extends HTMLElement | null, K extends keyof HTMLElementEventMap>(
  target: React.RefObject<T>,
  type: K,
  listener: (ev: HTMLElementEventMap[K]) => void,
  options?: boolean | AddEventListenerOptions,
): void;

function useEventListener(
  target: Window | React.RefObject<HTMLElement | null>,
  type: string,
  listener: EventListenerOrEventListenerObject,
  options?: boolean | AddEventListenerOptions,
): void {
  useEffect(() => {
    const node = target === window ? window : target.current;
    if (!node) return;
    node.addEventListener(type, listener as EventListener, options);
    return () => node.removeEventListener(type, listener as EventListener, options);
  }, [target, type, listener, options]);
}

Overload signatures give precise events for window versus HTMLElement; the implementation stays small. The pattern illustrates a broader rule: push specificity into overloads or generics, keep effects readable.

Testing hooks without full component trees

Typed hooks should be testable in isolation. renderHook from React Testing Library runs a hook inside a minimal wrapper, which pairs naturally with provider composition:

import { renderHook } from "@testing-library/react"

function wrapper({ children }: { children: React.ReactNode }) {
  return <ThemeProvider>{children}</ThemeProvider>
}

const { result } = renderHook(() => useTheme(), { wrapper })

For hooks that do not need a provider, omit the wrapper and assert state transitions directly. Extract pure reducers or state machines into plain functions when logic grows—those functions are trivially unit testable without React, and TypeScript signatures stay identical to what the hook exposes.

Performance and selectors

TypeScript does not reduce rerenders. If context value identity churns often, split providers or use a store with selectors (useStore((s) => s.user.id) with a typed s). Narrower types and narrower subscriptions reinforce each other: consumers request only what they need, both at the type level and at the subscription level.

Runtime validation for persisted state

Types do not validate disk or query strings. Pair hooks with a schema: parse unknown, then narrow to T.

import { z } from "zod";

const PreferencesSchema = z.object({ theme: z.enum(["light", "dark"]) });
type Preferences = z.infer<typeof PreferencesSchema>;

function readPreferences(raw: string | null): Preferences {
  if (!raw) return { theme: "light" };
  const parsed = JSON.parse(raw) as unknown;
  return PreferencesSchema.parse(parsed);
}

The hook can still expose [Preferences, (v: Preferences) => void]; the safety story moves from “trust JSON” to “schema or controlled fallback.”

Closing thought

Context and hooks are where application state meets React’s rules. Type-safe context rejects the undefined! fiction; throwing hooks document invariants; generics carry domain types through storage and async. Align those pieces and your components inherit the guarantees—fewer silent failures, clearer contracts, faster refactors—without sacrificing the flexibility that made you reach for Context in the first place.