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.