Skip to content
TypeScript ts react 4 min read

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 Provider and the useAuth hook, but keep the raw AuthContext module-private. Consumers should never call useContext(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.

ApproachContext typeTrade-off
undefined default + guard hookT | undefinedsafest; clear error outside provider
Realistic default objectTno guard, but silent no-op outside provider
null as unknown as T castTconcise 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 useContext in a custom hook that throws when the value is undefined.
  • Return the narrowed T from that hook so consumers never see undefined.
  • Annotate the provider’s value with 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 value object (useMemo) so consumers do not re-render on every parent render.
Last updated June 29, 2026
Was this helpful?