Follow AiTechWorlds on LinkedIn for professional AI content!Follow Now →
20 minLesson 7 of 33
Advanced React Patterns

Context API & useContext

Context API & useContext

Prop drilling is the problem Context solves. When state needs to reach deeply nested components, passing it through every intermediate component is messy and fragile. Context lets you beam data directly to any component that needs it, without wiring it through the whole tree.

The Problem: Prop Drilling

// App has the user. ProductDetailPage needs the user. But it has to pass through everything:

function App() {
    const user = { name: "Alice", role: "admin" };
    return <Layout user={user} />;
}

function Layout({ user }: { user: User }) {
    return <Sidebar user={user} />;    // Sidebar doesn't use user, just passes it
}

function Sidebar({ user }: { user: User }) {
    return <UserMenu user={user} />;   // UserMenu doesn't use it either
}

function UserMenu({ user }: { user: User }) {
    return <p>Hello, {user.name}</p>;  // Finally — used here
}

Every component in the chain must accept and forward user even though they don't care about it. Context eliminates this.

Creating and Using Context

// src/context/auth.tsx
import { createContext, useContext, useState, ReactNode } from "react";

interface User {
    id: string;
    name: string;
    email: string;
    role: "user" | "admin";
}

interface AuthContextValue {
    user: User | null;
    login: (email: string, password: string) => Promise<void>;
    logout: () => void;
    isLoading: boolean;
}

// 1. Create the context with a sensible default (or null)
const AuthContext = createContext<AuthContextValue | null>(null);

// 2. Create the provider component
export function AuthProvider({ children }: { children: ReactNode }) {
    const [user, setUser] = useState<User | null>(null);
    const [isLoading, setIsLoading] = useState(false);
    
    async function login(email: string, password: string) {
        setIsLoading(true);
        try {
            const response = await fetch("/api/auth/login", {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify({ email, password }),
            });
            const userData = await response.json();
            setUser(userData);
        } finally {
            setIsLoading(false);
        }
    }
    
    function logout() {
        setUser(null);
        fetch("/api/auth/logout", { method: "POST" });
    }
    
    return (
        <AuthContext.Provider value={{ user, login, logout, isLoading }}>
            {children}
        </AuthContext.Provider>
    );
}

// 3. Custom hook for clean access (with error handling)
export function useAuth() {
    const context = useContext(AuthContext);
    if (!context) {
        throw new Error("useAuth must be used inside an AuthProvider");
    }
    return context;
}
// Wrap your app with the provider
// src/main.tsx or src/app/layout.tsx
<AuthProvider>
    <App />
</AuthProvider>
// Any component, anywhere in the tree
function UserMenu() {
    const { user, logout } = useAuth();   // No prop drilling needed
    
    return (
        <div>
            <p>Hello, {user?.name}</p>
            <button onClick={logout}>Log out</button>
        </div>
    );
}

Multiple Contexts

Use separate contexts for separate concerns — don't put everything in one giant context:

// Auth context
<AuthProvider>
    // Theme context
    <ThemeProvider>
        // Cart context
        <CartProvider>
            <App />
        </CartProvider>
    </ThemeProvider>
</AuthProvider>

Theme Context Example

// src/context/theme.tsx
import { createContext, useContext, useState, useEffect, ReactNode } from "react";

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

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

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

export function ThemeProvider({ children }: { children: ReactNode }) {
    const [theme, setTheme] = useState<Theme>(() => {
        return (localStorage.getItem("theme") as Theme) ?? "system";
    });
    
    const resolvedTheme: "light" | "dark" = 
        theme === "system"
            ? window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
            : theme;
    
    useEffect(() => {
        localStorage.setItem("theme", theme);
        document.documentElement.classList.toggle("dark", resolvedTheme === "dark");
    }, [theme, resolvedTheme]);
    
    return (
        <ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
            {children}
        </ThemeContext.Provider>
    );
}

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

When Context Causes Re-renders

Every component consuming a context re-renders when the context value changes. If your context value is an object created inline, it changes on every parent render:

// ❌ New object every render = all consumers re-render every time
<MyContext.Provider value={{ user, setUser }}>

// ✅ Memoize the value to prevent unnecessary re-renders
const value = useMemo(() => ({ user, setUser }), [user]);
<MyContext.Provider value={value}>

Or split the context: one for data (changes frequently), one for actions (stable functions):

const UserContext = createContext<User | null>(null);
const UserActionsContext = createContext<{ setUser: (u: User) => void } | null>(null);

// Components that only need actions don't re-render when user data changes

Context with useReducer

For complex state, combine context with useReducer (covered in the next lesson):

interface CartState {
    items: CartItem[];
}

type CartAction =
    | { type: "ADD_ITEM"; item: CartItem }
    | { type: "REMOVE_ITEM"; id: string }
    | { type: "CLEAR" };

function cartReducer(state: CartState, action: CartAction): CartState {
    switch (action.type) {
        case "ADD_ITEM":
            return { items: [...state.items, action.item] };
        case "REMOVE_ITEM":
            return { items: state.items.filter(i => i.id !== action.id) };
        case "CLEAR":
            return { items: [] };
    }
}

const CartContext = createContext<{ state: CartState; dispatch: React.Dispatch<CartAction> } | null>(null);

export function CartProvider({ children }: { children: ReactNode }) {
    const [state, dispatch] = useReducer(cartReducer, { items: [] });
    
    return (
        <CartContext.Provider value={{ state, dispatch }}>
            {children}
        </CartContext.Provider>
    );
}

When Not to Use Context

Context is not a replacement for all state. Over-using it leads to unnecessary re-renders and complex debugging.

Use context for:

  • Authentication / current user
  • Theme / color scheme
  • Language / locale
  • Shopping cart (app-wide)
  • Feature flags

Use local state for:

  • Form field values
  • Open/closed state of a dropdown
  • Hover states
  • Any state that's only relevant to one component

Next lesson: useReducer for complex state — managing state machines and multi-action state.

📱

Get this course's notes on Telegram!

Free cheat sheets, summaries & practice exercises

Get Notes Free →
!