Frontend

React Context API: Global State Management

Mayur Dabhi
Mayur Dabhi
April 24, 2026
14 min read

Every React developer encounters the same problem eventually: you have data that multiple components need, but those components are buried deep in the component tree. Passing props down through five or six levels of intermediary components — components that don't even use those props — is painful, error-prone, and makes your code brittle. This is prop drilling, and React Context API was built specifically to solve it.

Introduced in React 16.3 and significantly improved with React Hooks in 16.8, the Context API gives you a way to share values between components without explicitly threading them through every level of the tree. When used correctly, it's a powerful built-in alternative to libraries like Redux for many real-world use cases.

When to Use Context API

Context is ideal for data that is global within a component tree: the current authenticated user, theme preferences, selected locale/language, or shopping cart contents. If state only affects a few sibling components, lift state up instead. Context is not a silver bullet — overusing it leads to unnecessary re-renders and harder-to-trace data flows.

The Problem: Prop Drilling

Before diving into Context, let's visualize the exact problem it solves. Imagine an app where a user object needs to reach a deeply nested component:

App user = fetchUser() Layout props.user ↓ (unused) Sidebar props.user ↓ (unused) UserCard finally uses user! Props passed through 3 layers that don't need it

Prop drilling: data flows through components that don't need it

With Context API, the App component places the user value into a context, and UserCard reads it directly — no middlemen required.

Core Concepts: Context, Provider, Consumer

The Context API is built around three primitives:

Creating a Context

src/context/UserContext.js
import { createContext } from 'react';

// The argument to createContext is the DEFAULT value —
// used only when a component has no matching Provider above it.
// null is a common default when a Provider is always required.
const UserContext = createContext(null);

export default UserContext;

Wrapping with a Provider

The Provider component accepts a value prop and makes it available to all descendants. Any component in the tree that reads this context will receive this value:

src/App.jsx
import { useState, useEffect } from 'react';
import UserContext from './context/UserContext';
import Layout from './components/Layout';

function App() {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetchCurrentUser().then(data => {
      setUser(data);
      setLoading(false);
    });
  }, []);

  if (loading) return <div>Loading...</div>;

  // value prop is what consumers will receive
  return (
    <UserContext.Provider value={{ user, setUser }}>
      <Layout />
    </UserContext.Provider>
  );
}

export default App;

Consuming Context with useContext

src/components/UserCard.jsx
import { useContext } from 'react';
import UserContext from '../context/UserContext';

function UserCard() {
  // Reads from the nearest matching Provider above in the tree
  const { user } = useContext(UserContext);

  if (!user) return null;

  return (
    <div className="user-card">
      <img src={user.avatar} alt={user.name} />
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

export default UserCard;

Notice that Layout and Sidebar no longer need to accept or forward a user prop. They can remain completely unaware of the user data.

Building a Complete Context Pattern

A best practice is to encapsulate context creation, provider logic, and the consumer hook into a single file. This gives you a clean, self-contained module that's easy to import and hard to misuse.

src/context/AuthContext.jsx
import { createContext, useContext, useState, useEffect } from 'react';

// 1. Create the context (not exported directly)
const AuthContext = createContext(null);

// 2. Build the Provider component with all state logic
export function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const [token, setToken] = useState(() => localStorage.getItem('token'));

  useEffect(() => {
    if (token) {
      validateToken(token)
        .then(userData => setUser(userData))
        .catch(() => {
          localStorage.removeItem('token');
          setToken(null);
        });
    }
  }, [token]);

  const login = async (email, password) => {
    const { user: userData, token: newToken } = await apiLogin(email, password);
    localStorage.setItem('token', newToken);
    setToken(newToken);
    setUser(userData);
  };

  const logout = () => {
    localStorage.removeItem('token');
    setToken(null);
    setUser(null);
  };

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

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

// 3. Custom hook — the only way consumers should access this context
export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
}
Always Validate Context Usage

The throw new Error guard inside useAuth is intentional. If a developer accidentally uses useAuth() outside the AuthProvider, they get an immediate, descriptive error instead of a cryptic null value bug that surfaces later. This pattern catches mistakes at development time.

Using the Auth Context

src/components/Navbar.jsx
import { useAuth } from '../context/AuthContext';
import { Link } from 'react-router-dom';

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

  return (
    <nav>
      <Link to="/">Home</Link>
      {isAuthenticated ? (
        <>
          <span>Welcome, {user.name}!</span>
          <button onClick={logout}>Logout</button>
        </>
      ) : (
        <Link to="/login">Login</Link>
      )}
    </nav>
  );
}

export default Navbar;

Theme Context: A Practical Example

Theme switching is one of the most common use cases for Context. Here's a complete, production-ready implementation that persists the user's preference:

src/context/ThemeContext.jsx
import { createContext, useContext, useState, useEffect } from 'react';

const ThemeContext = createContext(null);

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState(() => {
    // Read from localStorage on first render, fall back to system preference
    const saved = localStorage.getItem('theme');
    if (saved) return saved;
    return window.matchMedia('(prefers-color-scheme: dark)').matches
      ? 'dark'
      : 'light';
  });

  useEffect(() => {
    // Apply theme to the document root
    document.documentElement.setAttribute('data-theme', theme);
    localStorage.setItem('theme', theme);
  }, [theme]);

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

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

export const useTheme = () => {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
  return ctx;
};
src/components/ThemeToggle.jsx
import { useTheme } from '../context/ThemeContext';

function ThemeToggle() {
  const { theme, toggleTheme } = useTheme();

  return (
    <button onClick={toggleTheme} aria-label="Toggle theme">
      {theme === 'dark' ? '☀️ Light Mode' : '🌙 Dark Mode'}
    </button>
  );
}

export default ThemeToggle;

Performance: Avoiding Unnecessary Re-renders

The biggest pitfall with Context API is performance. Every time the context value changes, all components that consume that context will re-render — even if the specific piece of data they use hasn't changed. Here are strategies to keep your app fast:

Strategy 1: Split Contexts by Update Frequency

If you have state that changes frequently alongside state that rarely changes, keep them in separate contexts. Components only re-render when their specific context changes:

Split contexts by frequency
// BAD: one context for everything — all consumers re-render on any change
const AppContext = createContext({ user, theme, cart, notifications });

// GOOD: separate contexts — components only subscribe to what they use
const UserContext = createContext(null);        // Changes rarely
const ThemeContext = createContext(null);       // Changes on user toggle
const CartContext = createContext(null);        // Changes frequently
const NotificationsContext = createContext(null); // Changes on events

Strategy 2: Memoize the Context Value

If your Provider's parent component re-renders, the context value object is recreated on every render, triggering unnecessary consumer re-renders. Wrap the value with useMemo to prevent this:

Memoizing context value
import { createContext, useContext, useState, useMemo } from 'react';

const CartContext = createContext(null);

export function CartProvider({ children }) {
  const [items, setItems] = useState([]);

  const addItem = (product) =>
    setItems(prev => [...prev, product]);

  const removeItem = (id) =>
    setItems(prev => prev.filter(item => item.id !== id));

  const total = items.reduce((sum, item) => sum + item.price, 0);

  // Without useMemo, { items, addItem, removeItem, total } is a new
  // object reference on every render, re-rendering all consumers.
  const value = useMemo(
    () => ({ items, addItem, removeItem, total }),
    [items] // Only recreate when items actually changes
  );

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

export const useCart = () => useContext(CartContext);

Strategy 3: Separate State and Dispatch

For complex state, use useReducer and put state and dispatch into separate contexts. Components that only dispatch actions (like buttons) won't re-render when state changes:

useReducer with split contexts
import { createContext, useContext, useReducer } from 'react';

const CountStateContext = createContext(null);
const CountDispatchContext = createContext(null);

function countReducer(state, action) {
  switch (action.type) {
    case 'increment': return { count: state.count + 1 };
    case 'decrement': return { count: state.count - 1 };
    case 'reset':     return { count: 0 };
    default: throw new Error(`Unknown action: ${action.type}`);
  }
}

export function CountProvider({ children }) {
  const [state, dispatch] = useReducer(countReducer, { count: 0 });

  return (
    <CountStateContext.Provider value={state}>
      <CountDispatchContext.Provider value={dispatch}>
        {children}
      </CountDispatchContext.Provider>
    </CountStateContext.Provider>
  );
}

// Components that display count — re-render when count changes
export const useCountState = () => useContext(CountStateContext);

// Components that only dispatch — never re-render due to count changes
export const useCountDispatch = () => useContext(CountDispatchContext);

// Usage in a display component:
function Counter() {
  const { count } = useCountState();
  return <h1>Count: {count}</h1>;
}

// Usage in a button component — won't re-render when count changes!
function IncrementButton() {
  const dispatch = useCountDispatch();
  return <button onClick={() => dispatch({ type: 'increment' })}>+</button>;
}

Real-World: Shopping Cart Context

Let's put everything together with a complete shopping cart implementation that you'd actually ship:

// src/context/CartContext.jsx
import { createContext, useContext, useReducer, useMemo } from 'react';

const CartContext = createContext(null);

const cartReducer = (state, action) => {
  switch (action.type) {
    case 'ADD_ITEM': {
      const exists = state.items.find(i => i.id === action.payload.id);
      if (exists) {
        return {
          ...state,
          items: state.items.map(i =>
            i.id === action.payload.id
              ? { ...i, quantity: i.quantity + 1 }
              : i
          ),
        };
      }
      return { ...state, items: [...state.items, { ...action.payload, quantity: 1 }] };
    }
    case 'REMOVE_ITEM':
      return { ...state, items: state.items.filter(i => i.id !== action.payload) };
    case 'UPDATE_QUANTITY':
      return {
        ...state,
        items: state.items.map(i =>
          i.id === action.payload.id
            ? { ...i, quantity: action.payload.quantity }
            : i
        ),
      };
    case 'CLEAR_CART':
      return { ...state, items: [] };
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
};

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

  const value = useMemo(() => ({
    items: state.items,
    itemCount: state.items.reduce((sum, i) => sum + i.quantity, 0),
    total: state.items.reduce((sum, i) => sum + i.price * i.quantity, 0),
    addItem: (product) => dispatch({ type: 'ADD_ITEM', payload: product }),
    removeItem: (id) => dispatch({ type: 'REMOVE_ITEM', payload: id }),
    updateQuantity: (id, quantity) =>
      dispatch({ type: 'UPDATE_QUANTITY', payload: { id, quantity } }),
    clearCart: () => dispatch({ type: 'CLEAR_CART' }),
  }), [state]);

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

export const useCart = () => {
  const ctx = useContext(CartContext);
  if (!ctx) throw new Error('useCart must be used within CartProvider');
  return ctx;
};
// src/components/Cart.jsx
import { useCart } from '../context/CartContext';

function Cart() {
  const { items, total, itemCount, removeItem, updateQuantity, clearCart } = useCart();

  if (items.length === 0) {
    return <div className="cart-empty">Your cart is empty</div>;
  }

  return (
    <div className="cart">
      <div className="cart-header">
        <h2>Cart ({itemCount} items)</h2>
        <button onClick={clearCart}>Clear All</button>
      </div>

      {items.map(item => (
        <div key={item.id} className="cart-item">
          <img src={item.image} alt={item.name} />
          <div>
            <h3>{item.name}</h3>
            <p>${item.price.toFixed(2)}</p>
          </div>
          <div className="quantity-control">
            <button onClick={() => updateQuantity(item.id, item.quantity - 1)}
                    disabled={item.quantity === 1}>-</button>
            <span>{item.quantity}</span>
            <button onClick={() => updateQuantity(item.id, item.quantity + 1)}>+</button>
          </div>
          <button onClick={() => removeItem(item.id)}>Remove</button>
        </div>
      ))}

      <div className="cart-footer">
        <strong>Total: ${total.toFixed(2)}</strong>
        <button className="checkout-btn">Checkout</button>
      </div>
    </div>
  );
}

export default Cart;
// src/components/ProductCard.jsx
import { useCart } from '../context/CartContext';

function ProductCard({ product }) {
  const { items, addItem } = useCart();

  const isInCart = items.some(item => item.id === product.id);

  return (
    <div className="product-card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>${product.price.toFixed(2)}</p>
      <button
        onClick={() => addItem(product)}
        className={isInCart ? 'in-cart' : ''}
      >
        {isInCart ? '✓ In Cart' : 'Add to Cart'}
      </button>
    </div>
  );
}

export default ProductCard;

Context API vs Redux: When to Choose What

A common question: should you use Context API or Redux? The honest answer is it depends on your application's complexity and team size. Here's an objective comparison:

Criteria Context API Redux (+ Redux Toolkit)
Setup complexity Minimal — built into React Moderate — extra packages
Boilerplate Very little Reduced with Redux Toolkit
DevTools React DevTools only Redux DevTools (time-travel)
Performance at scale Requires careful splitting Built-in selector optimization
Middleware (async) Manual (useReducer + custom) Redux Thunk / RTK Query
State persistence Manual implementation redux-persist library
Testing Simple — wrap in Provider More setup required
Best for Small-medium apps, isolated feature state Large apps, complex state interactions
The Performance Trade-off

Context API does not have built-in selectors. If component A only needs user.name but the context value includes user.preferences, A will still re-render when preferences change. Redux with useSelector re-renders a component only when the selected slice of state changes. For high-frequency updates (e.g., real-time feeds, animations), Redux or Zustand may be better choices.

Composing Multiple Providers

Real applications use multiple contexts. Rather than nesting them manually, create a single AppProviders wrapper to keep your root clean:

src/providers/AppProviders.jsx
import { AuthProvider } from '../context/AuthContext';
import { ThemeProvider } from '../context/ThemeContext';
import { CartProvider } from '../context/CartContext';
import { NotificationsProvider } from '../context/NotificationsContext';

// Compose providers into a single wrapper — order matters!
// Outer providers are available to inner providers.
export function AppProviders({ children }) {
  return (
    <ThemeProvider>
      <AuthProvider>
        <NotificationsProvider>
          <CartProvider>
            {children}
          </CartProvider>
        </NotificationsProvider>
      </AuthProvider>
    </ThemeProvider>
  );
}
src/main.jsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { AppProviders } from './providers/AppProviders';
import App from './App';

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <BrowserRouter>
      <AppProviders>
        <App />
      </AppProviders>
    </BrowserRouter>
  </React.StrictMode>
);

Testing Components that Use Context

Testing is straightforward: wrap the component under test in the Provider with a controlled value. You can also mock the context value directly with a helper wrapper:

src/components/__tests__/UserCard.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AuthProvider } from '../../context/AuthContext';
import UserCard from '../UserCard';

// Helper: render with providers
function renderWithAuth(ui, { user = null } = {}) {
  return render(
    <AuthContext.Provider value={{ user, isAuthenticated: !!user }}>
      {ui}
    </AuthContext.Provider>
  );
}

describe('UserCard', () => {
  test('renders user name and email when authenticated', () => {
    const mockUser = { name: 'Mayur Dabhi', email: 'mayur@example.com' };
    renderWithAuth(<UserCard />, { user: mockUser });

    expect(screen.getByText('Mayur Dabhi')).toBeInTheDocument();
    expect(screen.getByText('mayur@example.com')).toBeInTheDocument();
  });

  test('renders nothing when user is null', () => {
    const { container } = renderWithAuth(<UserCard />, { user: null });
    expect(container).toBeEmptyDOMElement();
  });
});

Context API Best Practices Summary

PracticeWhy It Matters
Export a custom hook, not the context Enables guard clause and cleaner API for consumers
Co-locate context + provider + hook in one file Reduces import clutter and keeps related code together
Use useMemo for context value Prevents unnecessary consumer re-renders
Split contexts by update frequency Only components needing that specific data re-render
Separate state and dispatch contexts Action-only components don't re-render on state change
Compose providers into AppProviders Keeps root clean, easy to add/remove providers
Don't use Context for server state Use React Query or SWR for data fetching/caching

Key Takeaways

The React Context API is a powerful, zero-dependency solution for global state in React applications. Here's what you should carry forward:

What We Covered

  • Prop drilling problem: Context eliminates the need to pass props through intermediary components
  • Core API: createContext + Provider + useContext are all you need
  • Custom hook pattern: Always export a custom hook with a guard clause instead of exposing the context directly
  • Performance: Use useMemo, split contexts, and separate state/dispatch contexts to prevent unnecessary re-renders
  • useReducer: Pairs naturally with Context for complex state that has multiple transition types
  • Testing: Wrap component under test in a Provider with controlled values — simple and effective
  • When to upgrade: Switch to Redux/Zustand when you need time-travel debugging, fine-grained selectors, or complex middleware
"Context is primarily used when some data needs to be accessible by many components at different nesting levels. Apply it sparingly because it makes component reuse more difficult."
— React Documentation

React Context API strikes the right balance for most applications: no extra dependencies, minimal boilerplate, and enough power to handle themes, auth, carts, and feature flags with ease. Start with Context, measure your performance, and only reach for heavier tools when the data clearly demands it.

React Context State useContext JavaScript Frontend
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.