Skip to main content

πŸ”Œ Context API and Global State

Some data β€” the signed-in user, the color theme, the shopping cart β€” is needed all over your app. Threading it through props by hand gets painful fast. React's Context API lets any component "tune in" to shared state directly, and in this lesson you'll learn to wield it well without wrecking performance.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain prop drilling and how Context eliminates it
  • Create a context with createContext, provide it with a Provider, and consume it with useContext
  • Wrap a context in a custom hook with a safety guard, the pattern used in production codebases
  • Combine Context with useReducer to manage complex global state
  • Diagnose and fix Context re-render performance problems, and decide when a state library is a better fit

Estimated Time: 35–45 minutes  β€’  Difficulty: Intermediate

Hands-on: Build a theme context with a custom useTheme hook and wire a toggle button to it.

In This Lesson

The Problem: Prop Drilling

In React, data flows down through props: a parent hands values to its children. That is clean when the data has a short trip to make. But imagine the signed-in user lives at the top of your app and is needed by a tiny UserMenu buried five levels deep. Every component in between has to accept user and pass it along β€” even though none of them use it.

This is prop drilling: pushing props through layers of components that don't care about them, just to reach the one that does.

flowchart TD A["App (owns user)"] --> B[Layout] B --> C[Header] C --> D[Navigation] D --> E[UserMenu] E --> F["UserProfile β€” finally uses user"] style A fill:#fef3c7,stroke:#f59e0b,stroke-width:2px style F fill:#dcfce7,stroke:#22c55e,stroke-width:2px

Four components (Layout, Header, Navigation, UserMenu) become couriers for data they never read. The costs add up:

  • Noise β€” every intermediate component's signature is cluttered with pass-through props.
  • Fragility β€” add one new field and you edit five files.
  • Reduced reuse β€” those middle components are now welded to a specific prop shape.
πŸ’‘ Not every shared value needs Context. Prop drilling through one or two levels is perfectly fine β€” it's explicit and easy to follow. Context earns its keep when data is truly "global" and travels far.

What Context Actually Is

Context lets a component broadcast a value to its entire subtree. Any descendant can read that value directly, no matter how deep, without a single prop being passed by hand.

πŸ“– The Radio-Station Analogy

A Provider is a radio station broadcasting on a frequency. Any component inside it is within range and can "tune in" with useContext to receive whatever is currently on the air. Change what's broadcast and every tuned-in listener updates at once β€” no wires run between the station and each radio.

A Provider broadcasting a value to deep descendants A Provider at the top wraps a tree of components; three deeply nested components read the value directly from the Provider via dashed connections, skipping the components in between. ThemeContext.Provider value = { theme, toggleTheme } Header Sidebar Footer ThemeButton useContext Copyright useContext
Figure 1 β€” The Provider broadcasts its value. Deep consumers read it directly (dashed lines), bypassing the components between them and the Provider.

Good candidates for Context are values that many, scattered components need: the current theme, the authenticated user, the app's language, or UI flags like whether a sidebar is open.

Context in Three Steps

Every context follows the same rhythm: create it, provide it, consume it. Here is a complete theme example in modern React (function components + hooks).

Step 1 β€” Create the context

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

// createContext returns an object holding a Provider (and legacy Consumer).
// The argument is a *default* value, used only when a component reads the
// context without a matching Provider above it.
const ThemeContext = createContext(null);

Step 2 β€” Provide a value

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');

  const toggleTheme = () =>
    setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));

  // Everything inside <ThemeProvider> can now read { theme, toggleTheme }.
  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

Step 3 β€” Consume it anywhere below

function ThemeButton() {
  const { theme, toggleTheme } = useContext(ThemeContext);

  return (
    <button onClick={toggleTheme}>
      Switch to {theme === 'light' ? 'dark' : 'light'} mode
    </button>
  );
}

function App() {
  return (
    <ThemeProvider>
      <Header />
      <main>
        {/* ThemeButton is nested deep, yet reads the theme directly */}
        <ThemeButton />
      </main>
    </ThemeProvider>
  );
}

βœ… The key win

ThemeButton can sit ten components deep and still reach the theme with one line. No intermediate component knows or cares that a theme exists.

The Custom-Hook Pattern

Calling useContext(ThemeContext) directly works, but production codebases almost always wrap it in a custom hook. This does two things: it hides the raw context object, and it lets you throw a helpful error if a component tries to use the context outside its Provider.

// theme-context.jsx
import { createContext, useContext, useState } from 'react';

const ThemeContext = createContext(null);

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');
  const toggleTheme = () =>
    setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));

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

// The custom hook: consume + guard in one place.
export function useTheme() {
  const context = useContext(ThemeContext);
  if (context === null) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  return context;
}

Now components import a clean, purpose-built hook and never touch useContext or the context object:

import { useTheme } from './theme-context';

function ThemeButton() {
  const { theme, toggleTheme } = useTheme();
  return <button onClick={toggleTheme}>Current: {theme}</button>;
}

⚠️ Why the guard matters

If someone renders ThemeButton outside a ThemeProvider, the raw useContext silently returns the default value and you get a confusing bug far from its cause. The throw turns that into an immediate, named error that points straight at the mistake.

Context + useReducer for Complex State

Simple useState is fine for a theme flag. But when global state has many fields and many kinds of transitions β€” a cart, a notification queue, an auth session β€” useReducer gives you a single, predictable place where every change is defined. Pair it with Context and you get a lightweight, Redux-flavored store built entirely from React primitives.

flowchart LR A[Component] -->|dispatch action| B[Reducer] B -->|returns new state| C[Context value] C -->|re-renders| A

Here is a shopping-cart store. Notice how all the update logic lives in one reducer, and the Provider exposes friendly helper functions so components never build raw action objects.

// cart-context.jsx
import { createContext, useContext, useReducer } from 'react';

const initialState = { items: [], itemCount: 0, total: 0 };

function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM': {
      const item = action.payload;
      const existing = state.items.find((i) => i.id === item.id);
      const items = existing
        ? state.items.map((i) =>
            i.id === item.id ? { ...i, qty: i.qty + 1 } : i
          )
        : [...state.items, { ...item, qty: 1 }];
      return recalc(items);
    }
    case 'REMOVE_ITEM':
      return recalc(state.items.filter((i) => i.id !== action.payload));
    case 'CLEAR':
      return initialState;
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

// Derive totals in one place so state never drifts out of sync.
function recalc(items) {
  return {
    items,
    itemCount: items.reduce((n, i) => n + i.qty, 0),
    total: items.reduce((sum, i) => sum + i.price * i.qty, 0),
  };
}

const CartContext = createContext(null);

export function CartProvider({ children }) {
  const [state, dispatch] = useReducer(cartReducer, initialState);

  const value = {
    ...state,
    addItem: (item) => dispatch({ type: 'ADD_ITEM', payload: item }),
    removeItem: (id) => dispatch({ type: 'REMOVE_ITEM', payload: id }),
    clear: () => dispatch({ type: 'CLEAR' }),
  };

  return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}

export function useCart() {
  const context = useContext(CartContext);
  if (context === null) {
    throw new Error('useCart must be used within a CartProvider');
  }
  return context;
}

Consuming it is a joy β€” components describe what should happen, never how:

function AddToCartButton({ product }) {
  const { addItem } = useCart();
  return <button onClick={() => addItem(product)}>Add to cart</button>;
}

function CartBadge() {
  const { itemCount } = useCart();
  return <span className="badge">πŸ›’ {itemCount}</span>;
}

State after adding two of item #7 and one of item #3:

{
  items: [
    { id: 7, name: "Mug", price: 12, qty: 2 },
    { id: 3, name: "Sticker", price: 3, qty: 1 }
  ],
  itemCount: 3,
  total: 27
}

The Re-render Trap

Context has one sharp edge you must respect: when a Provider's value changes, every component that reads that context re-renders β€” even components that only use a slice of the value that didn't change.

// ⚠️ Problem: user and theme share one context.
function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');

  // A NEW object every render β€” every consumer re-renders every time.
  const value = { user, setUser, theme, setTheme };

  return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}

There are two independent fixes, and good apps use both.

Fix 1 β€” Split contexts by concern

If theme and user change for unrelated reasons, they should not live together. Give each its own context so a theme flip never re-renders user-only components.

// Separate providers β€” a theme change no longer touches user consumers.
<UserProvider>
  <ThemeProvider>
    <App />
  </ThemeProvider>
</UserProvider>

Fix 2 β€” Memoize the value

Even within one context, wrap the value object in useMemo so a new object is created only when its real data changes, not on every render.

import { useMemo, useState } from 'react';

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');
  const toggleTheme = () =>
    setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));

  // Same object reference until `theme` actually changes.
  const value = useMemo(() => ({ theme, toggleTheme }), [theme]);

  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

πŸ’‘ Rule of thumb

Keep contexts small and focused, and memoize their values. If you find yourself fighting re-renders constantly, that is a signal your global state has outgrown Context β€” read on.

Context vs. State Libraries

Context is a transport mechanism for shared values, not a full state-management system. For richer needs, dedicated libraries add selectors (subscribe to just the slice you use), devtools, and less boilerplate. Here's how the popular options compare.

Tool Learning curve Boilerplate Fine-grained updates Best for
Context + useReducer Low (built in) Moderate No (needs manual work) Theme, auth, small–medium apps
Zustand Low Very low Yes (selectors) Most apps wanting a simple store
Redux Toolkit Moderate Moderate Yes (selectors) Large teams, strict patterns, devtools
TanStack Query Moderate Low Yes (per-query) Server state: fetching & caching

⚠️ A common mistake: server state in Context

Data fetched from an API (products, posts, the user's orders) is server state β€” it needs caching, refetching, and staleness handling. Context has none of that. Reach for a data-fetching library like TanStack Query for server data, and keep Context for genuine client state like theme and UI toggles.

The honest takeaway: reach for Context first because it's free and native. Graduate to a library when you feel real pain β€” constant re-render tuning, or state logic that's sprawling across many reducers.

Hands-on Exercise

πŸ‹οΈ Build a Theme Context

Objective: Create a working light/dark theme context with a custom hook and a toggle button, in a fresh React 18 + Vite app.

Instructions:

  1. Scaffold an app: npm create vite@latest theme-demo -- --template react, then cd theme-demo && npm install.
  2. Create src/theme-context.jsx that exports a ThemeProvider and a useTheme hook (with the out-of-provider guard).
  3. Wrap <App /> in <ThemeProvider> inside src/main.jsx.
  4. In App.jsx, call useTheme(), apply the theme as a class on the root element, and render a button that calls toggleTheme.
  5. Stretch goal: persist the choice by reading/writing localStorage so the theme survives a page reload.
πŸ’‘ Hint

For persistence, initialize state lazily: useState(() => localStorage.getItem('theme') ?? 'light'), then use an useEffect that writes to localStorage whenever theme changes. You'll cover useEffect in depth in the next lesson.

βœ… Solution
// src/theme-context.jsx
import { createContext, useContext, useEffect, useMemo, useState } from 'react';

const ThemeContext = createContext(null);

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState(
    () => localStorage.getItem('theme') ?? 'light'
  );

  useEffect(() => {
    localStorage.setItem('theme', theme);
    document.documentElement.dataset.theme = theme;
  }, [theme]);

  const value = useMemo(
    () => ({
      theme,
      toggleTheme: () =>
        setTheme((t) => (t === 'light' ? 'dark' : 'light')),
    }),
    [theme]
  );

  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

export function useTheme() {
  const ctx = useContext(ThemeContext);
  if (ctx === null) throw new Error('useTheme must be used within a ThemeProvider');
  return ctx;
}

// src/App.jsx
import { useTheme } from './theme-context';

export default function App() {
  const { theme, toggleTheme } = useTheme();
  return (
    <main>
      <h1>Current theme: {theme}</h1>
      <button onClick={toggleTheme}>Toggle theme</button>
    </main>
  );
}

🎯 Quick Quiz

Question 1: What problem is the Context API primarily designed to solve?

Question 2: Why do production codebases wrap useContext in a custom hook like useTheme?

Question 3: A single context holds both user and theme. Flipping the theme re-renders components that only read user. What's the cleanest fix?

Summary & Quiz

πŸŽ‰ Key Takeaways

  • Context broadcasts a value to a whole subtree, curing prop drilling for truly global data.
  • The rhythm is always create β†’ provide β†’ consume (createContext, <Provider>, useContext).
  • Wrap consumption in a custom hook with an out-of-provider guard β€” the standard production pattern.
  • Pair Context with useReducer to manage complex, multi-action global state.
  • Watch the re-render trap: split contexts by concern and memoize the value; graduate to a library (Zustand, Redux Toolkit) or a data-fetching tool (TanStack Query) when Context strains.

πŸ“š Further Reading

πŸš€ What's Next?

Our theme provider quietly used useEffect to sync with localStorage. Next up, Effects and Component Lifecycle takes useEffect apart in full β€” mounting, updating, cleanup, dependency arrays, and the mistakes that trip up nearly everyone.

πŸŽ‰ Nicely done!

You can now share state across an entire app without a single prop being drilled by hand.