On this page

Context API: Sharing State Without Prop Drilling

14 min read TextCh. 3 — Patterns and Navigation

The Prop Drilling Problem

Consider an application where user authentication state needs to be available in the Header, Sidebar, and deep inside various page components. Without Context, you would have to pass the user prop through every intermediate component — even components that do not use it directly:

// Prop drilling — every level must pass user down
function App() {
  const [user, setUser] = useState<User | null>(null);
  return <Layout user={user} onLogout={() => setUser(null)} />;
}

function Layout({ user, onLogout }: LayoutProps) {
  return (
    <>
      <Header user={user} onLogout={onLogout} />
      <Main user={user} />
    </>
  );
}

function Header({ user, onLogout }: HeaderProps) {
  return <nav><UserMenu user={user} onLogout={onLogout} /></nav>;
}

// UserMenu is the only component that actually needs user
function UserMenu({ user, onLogout }: UserMenuProps) {
  if (!user) return <a href="/login">Login</a>;
  return <button type="button" onClick={onLogout}>{user.name}</button>;
}

Context solves this by making values available to any descendant component without explicit prop passing.

Creating a Context

The pattern has three parts: create the context, provide it, and consume it.

import { createContext, useContext, useState, type ReactNode } from 'react';

// 1. Define the shape of the context value
interface AuthContextValue {
  user: User | null;
  isAuthenticated: boolean;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
}

// 2. Create the context (null as default — validated in the hook)
const AuthContext = createContext<AuthContextValue | null>(null);

The Provider Component

The Provider wraps the part of the tree that needs access to the context:

function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);

  async function login(email: string, password: string) {
    const response = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    });

    if (!response.ok) throw new Error('Invalid credentials');
    const userData = await response.json() as User;
    setUser(userData);
  }

  function logout() {
    setUser(null);
    // Clear any tokens, redirect, etc.
  }

  const value: AuthContextValue = {
    user,
    isAuthenticated: user !== null,
    login,
    logout,
  };

  return (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  );
}

The Consumer Hook

Always wrap useContext in a custom hook that validates the context is available:

function useAuth(): AuthContextValue {
  const ctx = useContext(AuthContext);
  if (!ctx) {
    throw new Error('useAuth must be called inside an AuthProvider');
  }
  return ctx;
}

This gives you a clear error message if you forget to add the Provider, rather than a cryptic Cannot read properties of null error.

Consuming Context in Components

function UserMenu() {
  const { user, isAuthenticated, logout } = useAuth();

  if (!isAuthenticated) {
    return <a href="/login">Sign in</a>;
  }

  return (
    <div className="user-menu">
      <img src={user!.avatarUrl} alt={user!.name} width={32} height={32} />
      <span>{user!.name}</span>
      <button type="button" onClick={logout}>Sign out</button>
    </div>
  );
}

function ProtectedPage() {
  const { isAuthenticated } = useAuth();

  if (!isAuthenticated) {
    return <p>Please log in to view this page.</p>;
  }

  return <main>Secret content</main>;
}

Locale/Internationalization Context

type Locale = 'en' | 'es' | 'fr';

interface LocaleContextValue {
  locale: Locale;
  setLocale: (locale: Locale) => void;
  t: (key: string) => string;
}

const translations: Record<Locale, Record<string, string>> = {
  en: { welcome: 'Welcome', logout: 'Sign out' },
  es: { welcome: 'Bienvenido', logout: 'Cerrar sesión' },
  fr: { welcome: 'Bienvenue', logout: 'Se déconnecter' },
};

const LocaleContext = createContext<LocaleContextValue | null>(null);

function LocaleProvider({ children }: { children: ReactNode }) {
  const [locale, setLocale] = useState<Locale>('en');

  function t(key: string): string {
    return translations[locale][key] ?? key;
  }

  return (
    <LocaleContext.Provider value={{ locale, setLocale, t }}>
      {children}
    </LocaleContext.Provider>
  );
}

function useLocale(): LocaleContextValue {
  const ctx = useContext(LocaleContext);
  if (!ctx) throw new Error('useLocale must be inside LocaleProvider');
  return ctx;
}

// Usage
function WelcomeBanner() {
  const { t, locale, setLocale } = useLocale();
  return (
    <div>
      <h1>{t('welcome')}</h1>
      <select
        value={locale}
        onChange={(e) => setLocale(e.target.value as Locale)}
        aria-label="Select language"
      >
        <option value="en">English</option>
        <option value="es">Español</option>
        <option value="fr">Français</option>
      </select>
    </div>
  );
}

Splitting Context for Performance

When a context holds multiple values that change at different rates, split it into separate contexts:

// Split into two contexts
const UserDataContext = createContext<User | null>(null);
const UserActionsContext = createContext<UserActions | null>(null);

function UserProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);

  // Actions object is stable — same reference every render
  const actions: UserActions = useMemo(() => ({
    updateName: (name: string) => setUser((u) => u ? { ...u, name } : u),
    updateEmail: (email: string) => setUser((u) => u ? { ...u, email } : u),
  }), []);

  return (
    <UserDataContext.Provider value={user}>
      <UserActionsContext.Provider value={actions}>
        {children}
      </UserActionsContext.Provider>
    </UserDataContext.Provider>
  );
}

// Components that only dispatch actions won't re-render when user data changes
function EditButton() {
  const actions = useContext(UserActionsContext)!;
  return (
    <button type="button" onClick={() => actions.updateName('New Name')}>
      Edit
    </button>
  );
}

Nesting Multiple Providers

In large applications, you compose multiple providers at the app root:

function App() {
  return (
    <AuthProvider>
      <ThemeProvider>
        <LocaleProvider>
          <RouterProvider router={router} />
        </LocaleProvider>
      </ThemeProvider>
    </AuthProvider>
  );
}

Consider creating a composed Providers component to keep App.tsx clean:

function Providers({ children }: { children: ReactNode }) {
  return (
    <AuthProvider>
      <ThemeProvider>
        <LocaleProvider>
          {children}
        </LocaleProvider>
      </ThemeProvider>
    </AuthProvider>
  );
}

In the next lesson, you will learn how to add client-side navigation to your app with React Router v7, including nested routes, dynamic params, and data loaders.

Always export a typed custom hook instead of the raw context
Export a `useTheme()` hook instead of the raw `ThemeContext`. This lets you add runtime validation (throwing an error if used outside the provider) and gives consumers a cleaner API. Never export the context object directly to consumers.
Context causes all consumers to re-render on change
Every component that calls `useContext(MyContext)` will re-render when the context value changes — even if the specific part it uses did not change. For high-frequency updates, consider splitting context into smaller pieces or using a dedicated state library like Zustand.
Do not put everything in context
Context is not a replacement for all state management. It is optimal for low-frequency global values: user session, theme, locale, and feature flags. For frequently-updated state (form fields, animations, list filters), keep state local or use TanStack Query / Zustand.
import {
  createContext,
  useContext,
  useState,
  useCallback,
  type ReactNode,
} from 'react';

type Theme = 'light' | 'dark' | 'system';

interface ThemeContextValue {
  theme: Theme;
  resolvedTheme: 'light' | 'dark';
  setTheme: (theme: Theme) => void;
  toggleTheme: () => void;
}

const ThemeContext = createContext<ThemeContextValue | null>(null);

function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setThemeState] = useState<Theme>(() => {
    const stored = localStorage.getItem('theme') as Theme | null;
    return stored ?? 'system';
  });

  const resolvedTheme: 'light' | 'dark' =
    theme === 'system'
      ? window.matchMedia('(prefers-color-scheme: dark)').matches
        ? 'dark'
        : 'light'
      : theme;

  const setTheme = useCallback((newTheme: Theme) => {
    setThemeState(newTheme);
    localStorage.setItem('theme', newTheme);
  }, []);

  const toggleTheme = useCallback(() => {
    setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
  }, [resolvedTheme, setTheme]);

  return (
    <ThemeContext.Provider value={{ theme, resolvedTheme, setTheme, toggleTheme }}>
      <div data-theme={resolvedTheme}>
        {children}
      </div>
    </ThemeContext.Provider>
  );
}

function useTheme(): ThemeContextValue {
  const ctx = useContext(ThemeContext);
  if (!ctx) {
    throw new Error('useTheme must be used inside ThemeProvider');
  }
  return ctx;
}

export { ThemeProvider, useTheme };
export type { Theme };