Typing Context
Context shares values down the tree without prop drilling, and TypeScript types both the value and every consumer of it. The recurring challenge is the default value: a context must be created with one, but there often is no sensible default outside a provider. The idiomatic fix is to default to undefined, type the context as T | undefined, and wrap useContext in a custom hook that throws when used outside its provider — collapsing the type back to T for callers. This page covers creating a typed context, the provider, the custom consumer hook, and splitting state from dispatch.
Creating a typed context
Pass the value’s type as the generic to createContext. Because consumers outside a provider would otherwise receive a bogus default, type the value as T | undefined and default to undefined.
import { createContext } from "react";
interface AuthState {
user: string | null;
login: (name: string) => void;
logout: () => void;
}
const AuthContext = createContext<AuthState | undefined>(undefined);
The <AuthState | undefined> generic is what forces every consumer to confront the “no provider” case. The alternative — inventing a fake default object — hides bugs, because a component rendered outside the provider would silently receive dummy functions instead of failing loudly.
Providing the value
The provider holds the real state (often in useState or useReducer) and passes it through value. TypeScript checks that the value matches the context’s type exactly.
import { useState, type ReactNode } from "react";
function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<string | null>(null);
const value: AuthState = {
user,
login: (name) => setUser(name),
logout: () => setUser(null),
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
Annotating value: AuthState (rather than relying on inference) means a missing or mistyped field is caught here, at the source, instead of surfacing as a confusing error in some distant consumer.
A custom hook that removes the undefined
Calling useContext(AuthContext) directly returns AuthState | undefined, forcing a null check at every call site. Wrap it once in a hook that throws when the value is missing — after the guard, TypeScript narrows the return to AuthState.
import { useContext } from "react";
function useAuth(): AuthState {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an <AuthProvider>");
}
return context; // narrowed from AuthState | undefined to AuthState
}
Consumers now write const { user, login } = useAuth(); with no undefined to handle and a clear runtime error if they forget the provider. This single guard is the whole payoff of the undefined default pattern.
function Navbar() {
const { user, logout } = useAuth(); // fully typed AuthState
return user ? <button onClick={logout}>Sign out {user}</button> : <span>Guest</span>;
}
Export the
Providerand theuseAuthhook, but keep the rawAuthContextmodule-private. Consumers should never calluseContext(AuthContext)directly and skip the guard.
Default value patterns compared
There are three ways to handle the required default; the undefined + guard hook is the most type-safe.
| Approach | Context type | Trade-off |
|---|---|---|
undefined default + guard hook | T | undefined | safest; clear error outside provider |
| Realistic default object | T | no guard, but silent no-op outside provider |
null as unknown as T cast | T | concise but unsafe; lies about runtime value |
Splitting state and dispatch
For large contexts, separate the read value from the updater into two contexts. Components that only dispatch then do not re-render when unrelated state changes, and each context gets its own typed hook.
const TodosContext = createContext<string[] | undefined>(undefined);
const TodosDispatchContext = createContext<React.Dispatch<Action> | undefined>(undefined);
function useTodos() {
const value = useContext(TodosContext);
if (value === undefined) throw new Error("useTodos needs a TodosProvider");
return value;
}
Each context follows the same T | undefined + guard recipe. Splitting keeps the types focused and is a useful performance lever once a context value changes often.
Best Practices
- Pass the value type to
createContext<T | undefined>(undefined)instead of inventing a default. - Wrap
useContextin a custom hook that throws when the value isundefined. - Return the narrowed
Tfrom that hook so consumers never seeundefined. - Annotate the provider’s
valuewith the context type to catch mistakes at the source. - Export the provider and the hook; keep the raw context object module-private.
- Split state and dispatch into separate contexts when the value updates frequently.
- Memoize the
valueobject (useMemo) so consumers do not re-render on every parent render.