React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

typescriptreactcontextcreateContextprovideruseContext

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 generic T defines the shape of the value that providers must supply and consumers will receive.
  • The null default + custom hook pattern avoids two problems: (1) inventing a fake default value that could mask bugs, and (2) forcing consumers to check for undefined everywhere.
  • The custom useTheme() hook narrows the type from ThemeContextType | null to ThemeContextType by throwing if the context is missing. This gives consumers a clean, non-nullable type.
  • React re-renders all consumers when the provider's value changes. Since { theme, toggleTheme } is a new object on every render, wrap it in useMemo if 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 a useState setter. Use it in context types when exposing a setter directly.
  • React.Dispatch<Action> is the type of a useReducer dispatch function.
  • Avoid using as to 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. The null + guard pattern is safer.
  • Putting a new object in value on every render causes all consumers to re-render. Use useMemo for 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 and useContext, and handle null themselves.

Alternatives

ApproachProsCons
null default + guard hookType-safe, clear error on misuseRequires custom hook per context
Non-null assertion ({} as T)No null checks neededRuntime crash if provider missing
Real default valueWorks without providerMust invent a meaningful default
Zustand or JotaiSimpler API, fine-grained subscriptionsExternal dependency
Module-level stateNo provider nestingNot 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 null default 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 | null to T with 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 value reference changes.
  • { theme, toggleTheme } creates a new object on every render, triggering re-renders.
  • Wrap the value in useMemo to 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) and setState(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 a useReducer dispatch 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 useMemo becomes 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>;
  • useMemo ensures the value object only changes when dependencies change.
  • This prevents unnecessary consumer re-renders.