Typing Context
Recipe
Create fully typed React contexts with createContext, typed providers, and type-safe useContext hooks. Handle the default value problem cleanly.
Working Example
// 1. Define the context type
type Theme = "light" | "dark";
type ThemeContextType = {
theme: Theme;
toggleTheme: () => void;
};
// 2. Create context with a sensible default or null
const ThemeContext = createContext<ThemeContextType | null>(null);
// 3. Create a typed hook with a runtime guard
function useTheme(): ThemeContextType {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}
// 4. Create the provider component
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>("light");
const toggleTheme = () => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
// 5. Consume in a component
function Header() {
const { theme, toggleTheme } = useTheme();
return (
<header className={theme}>
<button onClick={toggleTheme}>Current: {theme}</button>
</header>
);
}Deep Dive
How It Works
createContext<T>(defaultValue)creates a context object. The genericTdefines the shape of the value that providers must supply and consumers will receive.- The
nulldefault + custom hook pattern avoids two problems: (1) inventing a fake default value that could mask bugs, and (2) forcing consumers to check forundefinedeverywhere. - The custom
useTheme()hook narrows the type fromThemeContextType | nulltoThemeContextTypeby throwing if the context is missing. This gives consumers a clean, non-nullable type. - React re-renders all consumers when the provider's
valuechanges. Since{ theme, toggleTheme }is a new object on every render, wrap it inuseMemoif you have many consumers.
Variations
Context with useMemo for stable value:
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>("light");
const value = useMemo<ThemeContextType>(
() => ({
theme,
toggleTheme: () => setTheme((prev) => (prev === "light" ? "dark" : "light")),
}),
[theme]
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}Multiple contexts for separate concerns:
type AuthContextType = {
user: User | null;
login: (credentials: Credentials) => Promise<void>;
logout: () => void;
};
const AuthContext = createContext<AuthContextType | null>(null);
function useAuth(): AuthContextType {
const context = useContext(AuthContext);
if (!context) throw new Error("useAuth must be used within AuthProvider");
return context;
}Context with useReducer:
type AppState = { count: number; user: User | null };
type AppAction = { type: "increment" } | { type: "setUser"; payload: User };
type AppContextType = {
state: AppState;
dispatch: React.Dispatch<AppAction>;
};
const AppContext = createContext<AppContextType | null>(null);TypeScript Notes
React.Dispatch<React.SetStateAction<T>>is the type of auseStatesetter. Use it in context types when exposing a setter directly.React.Dispatch<Action>is the type of auseReducerdispatch function.- Avoid using
asto cast the default value:createContext({} as ThemeContextType)compiles but provides an invalid object at runtime if the provider is missing.
Gotchas
- Using
createContext({} as T)silences TypeScript but leads to runtime crashes when the provider is absent. Thenull+ guard pattern is safer. - Putting a new object in
valueon every render causes all consumers to re-render. UseuseMemofor the value object. - Splitting unrelated data into separate contexts prevents unnecessary re-renders. A single massive context re-renders every consumer when any field changes.
- The custom hook pattern (
useTheme,useAuth) is critical for good DX. Without it, consumers must import both the context anduseContext, and handle null themselves.
Alternatives
| Approach | Pros | Cons |
|---|---|---|
null default + guard hook | Type-safe, clear error on misuse | Requires custom hook per context |
Non-null assertion ({} as T) | No null checks needed | Runtime crash if provider missing |
| Real default value | Works without provider | Must invent a meaningful default |
| Zustand or Jotai | Simpler API, fine-grained subscriptions | External dependency |
| Module-level state | No provider nesting | Not reactive, not SSR-safe |
FAQs
Why use null as the default context value instead of an empty object?
createContext({} as T)provides an invalid object that silently causes runtime crashes if the provider is missing.- The
nulldefault combined with a guard hook gives a clear error message when the provider is absent. - It avoids inventing fake default values that could mask bugs.
How does the custom hook pattern (useTheme, useAuth) improve type safety?
function useTheme(): ThemeContextType {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within ThemeProvider");
}
return context; // narrowed to ThemeContextType
}- Narrows the type from
T | nulltoTwith a single runtime guard. - Consumers get a clean, non-nullable type without null checks.
Why does putting a new object in the Provider value on every render cause performance issues?
- React re-renders all consumers when the provider's
valuereference changes. { theme, toggleTheme }creates a new object on every render, triggering re-renders.- Wrap the value in
useMemoto produce a stable reference.
When should you split state into separate contexts?
- When unrelated data is grouped into a single context, all consumers re-render when any field changes.
- Separate contexts (e.g.,
AuthContext,ThemeContext) prevent unnecessary re-renders. - Split when consumers only need a subset of the data.
What is the type of a useState setter when exposed through context?
React.Dispatch<React.SetStateAction<T>>.- This allows both direct values and updater functions:
setState(5)andsetState(prev => prev + 1).
Gotcha: What happens if you use as T to cast the default context value?
createContext({} as ThemeContextType)compiles but provides an object with no real properties.- If a component renders outside the provider, it gets the empty object and crashes at runtime.
- The
null+ guard pattern is strictly safer.
How do you type a context that uses useReducer?
type AppContextType = {
state: AppState;
dispatch: React.Dispatch<AppAction>;
};
const AppContext = createContext<AppContextType | null>(null);React.Dispatch<Action>is the type of auseReducerdispatch function.- The context carries both state and dispatch to consumers.
When should you use Zustand or Jotai instead of React Context?
- When you need fine-grained subscriptions (only re-render when specific fields change).
- When the API complexity of providers, custom hooks, and
useMemobecomes excessive. - Context is sufficient for low-frequency updates like theme or auth state.
Gotcha: Can you use a real default value in createContext to avoid the null pattern?
- Yes, if you can provide a meaningful default that is safe to use without a provider.
- This removes the need for a custom guard hook.
- However, inventing plausible defaults (like no-op functions) can hide bugs when the provider is accidentally missing.
How do you stabilize the context value with useMemo?
const value = useMemo<ThemeContextType>(
() => ({
theme,
toggleTheme: () => setTheme((p) => (p === "light" ? "dark" : "light")),
}),
[theme]
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;useMemoensures the value object only changes when dependencies change.- This prevents unnecessary consumer re-renders.