Context API — Sharing State Without Prop Drilling

React’s Context API solves prop drilling — the tedious pattern of passing a value through many intermediate components just to reach a deeply nested consumer. Authentication state is the canonical example: the logged-in user’s name is needed in the header, the dashboard, and the post editor, but passing it from the root through every layout and page component is impractical. Context lets you place a value in a “tree-wide scope” and have any component consume it directly, without threading the prop through intermediaries.

Creating and Using Context

import { createContext, useContext, useState } from "react";

// ── 1. Create the context (and a custom hook to consume it) ───────────────────
const ThemeContext = createContext(null);   // null = no default value

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

// ── 2. Create the Provider ────────────────────────────────────────────────────
export function ThemeProvider({ children }) {
    const [theme, setTheme] = useState("light");

    function toggleTheme() {
        setTheme((t) => (t === "light" ? "dark" : "light"));
    }

    return (
        <ThemeContext.Provider value={{ theme, toggleTheme }}>
            {children}
        </ThemeContext.Provider>
    );
}

// ── 3. Wrap the app ───────────────────────────────────────────────────────────
// src/main.jsx:
// <ThemeProvider><BrowserRouter><App /></BrowserRouter></ThemeProvider>

// ── 4. Consume in any component without prop drilling ─────────────────────────
function Header() {
    const { theme, toggleTheme } = useTheme();
    return (
        <header className={theme === "dark" ? "bg-gray-900 text-white" : "bg-white"}>
            <button onClick={toggleTheme}>Toggle theme</button>
        </header>
    );
}
Note: Every component that calls useContext(ThemeContext) re-renders whenever the context value changes. If the context value is an object created inline in the Provider — value={{ theme, toggleTheme }} — a new object is created on every render of the Provider, even when theme has not changed. This causes all consumers to re-render unnecessarily. Fix: memoize the value with useMemo, or split the context into a “state context” and an “actions context” so stable actions do not trigger re-renders.
Tip: Always export a custom useXxx hook that wraps useContext rather than exporting the context object directly. The hook can throw a helpful error if used outside the provider (if (!ctx) throw new Error("useTheme must be inside ThemeProvider")), and it hides the implementation detail that this particular value comes from a context — you can later replace the context with a Zustand store without changing any consumer components.
Warning: Context is not a replacement for all state management. It is appropriate for infrequently-changing global values: the logged-in user, the active theme, a locale preference. For frequently-changing data (a list of posts that updates on every filter change), or for complex update logic, a dedicated store (Zustand, Redux Toolkit) or a data-fetching library (TanStack Query) is more appropriate. Using context for high-frequency updates causes performance problems because every consumer re-renders on every change.

When to Use Context vs Alternatives

Situation Best Tool Reason
Auth user, theme, locale Context API Infrequent changes, app-wide access
Two sibling components share state Lift state up Simple, no library needed
Server data (posts, comments) TanStack Query Caching, deduplication, background refetch
Complex UI state (cart, multi-step form) Zustand or useReducer Predictable updates, devtools
Frequently updated UI state Zustand Context re-renders all consumers

Common Mistakes

Mistake 1 — Inline object in Provider value (unnecessary re-renders)

❌ Wrong — new object on every render triggers all consumers:

function Provider({ children }) {
    const [user, setUser] = useState(null);
    return (
        <Ctx.Provider value={{ user, setUser }}>  {/* new object every render! */}
            {children}
        </Ctx.Provider>
    );
}

✅ Correct — memoize the value:

import { useMemo } from "react";
const value = useMemo(() => ({ user, setUser }), [user]);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;   // ✓

Mistake 2 — Using context without a custom hook (no error on misuse)

❌ Wrong — silent null if used outside provider:

export const AuthContext = createContext(null);
// Usage: const ctx = useContext(AuthContext) — ctx is null outside provider, crashes later

✅ Correct — export a hook with a guard:

export function useAuth() {
    const ctx = useContext(AuthContext);
    if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
    return ctx;
}

Quick Reference

Step Code
Create context const Ctx = createContext(null)
Provide value <Ctx.Provider value={memoizedValue}>{children}</Ctx.Provider>
Consume value const value = useContext(Ctx)
Custom hook export function useCtx() { const v = useContext(Ctx); if (!v) throw ...; return v; }

🧠 Test Yourself

An AuthContext holds { user, posts, likedPostIds }. likedPostIds changes every time a user likes a post. The Header component only uses user. How often does Header re-render?