Skip to main content

Command Palette

Search for a command to run...

useContext in Action: Consuming Context Like a Pro - React Guide

Master the useContext hook in React. Learn how to manage global state efficiently, avoid prop drilling, and implement the Context API with real-world patterns.

Updated
11 min read
useContext in Action: Consuming Context Like a Pro - React Guide

If you've read the previous article, you know what useContext is and how the Context API works under the hood. You've seen createContext, Provider, and the basic useContext call. Good. Now let's throw away the toy examples.

This article is about how experienced React engineers actually use useContext — the patterns that show up in real codebases, the mistakes that silently destroy performance, and the architectural decisions that make context scale without becoming a liability.


A Quick Reset: What You Already Know

const ThemeContext = createContext('light');

function App() {
  return (
    <ThemeContext.Provider value="dark">
      <Page />
    </ThemeContext.Provider>
  );
}

function Page() {
  const theme = useContext(ThemeContext);
  return <div className={theme}>Hello</div>;
}

This works. But in production, you'll almost never write context this raw. Let's build up from here.


Pattern 1: The Custom Hook Wrapper (The Standard)

Exposing the raw context object to consumers is a footgun. If someone calls useContext(ThemeContext) outside of a Provider, they get the default value silently — no error, no warning, just a bug that's hard to trace.

The fix is to wrap useContext in a custom hook that validates its environment:

// context/ThemeContext.js
import { createContext, useContext, useState } from 'react';

const ThemeContext = createContext(null);

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

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

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

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

Now consumers do this:

import { useTheme } from '@/context/ThemeContext';

function Navbar() {
  const { theme, toggle } = useTheme();
  return (
    <nav className={`navbar navbar--${theme}`}>
      <button onClick={toggle}>Switch to {theme === 'light' ? 'dark' : 'light'} mode</button>
    </nav>
  );
}

Why this matters:

  • The error boundary is clear and immediate — you know exactly where the context is missing.

  • Consumers never import the raw context object. They only import useTheme. This means you can completely refactor the internals later without touching a single consumer.

  • It reads like domain language, not React plumbing.


Pattern 2: Auth Context — The Most Common Production Use Case

Authentication state is the canonical use case for context. It's global, it changes infrequently, and nearly every component in the app needs to read it (but rarely write it).

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

const AuthContext = createContext(null);

export function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // Simulate token check on mount
    const storedUser = localStorage.getItem('user');
    if (storedUser) {
      setUser(JSON.parse(storedUser));
    }
    setLoading(false);
  }, []);

  const login = async (credentials) => {
    const response = await fakeAuthAPI(credentials);
    setUser(response.user);
    localStorage.setItem('user', JSON.stringify(response.user));
  };

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

  return (
    <AuthContext.Provider value={{ user, loading, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) throw new Error('useAuth must be used within AuthProvider');
  return context;
}

Now your protected routes become trivial to write:

function ProtectedRoute({ children }) {
  const { user, loading } = useAuth();

  if (loading) return <Spinner />;
  if (!user) return <Navigate to="/login" />;
  return children;
}

And your login button anywhere in the tree:

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

  return (
    <header>
      {user ? (
        <>
          <span>Welcome, {user.name}</span>
          <button onClick={logout}>Sign out</button>
        </>
      ) : (
        <a href="/login">Sign in</a>
      )}
    </header>
  );
}

No props. No drilling through App → Layout → Header. Clean.


Pattern 3: Splitting Context to Avoid Unnecessary Re-renders

Here's a performance trap most React developers hit once and never forget.

Imagine you have a shopping cart context:

// ❌ The naive version
const CartContext = createContext(null);

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

  const addItem = (item) => setItems(prev => [...prev, item]);
  const removeItem = (id) => setItems(prev => prev.filter(i => i.id !== id));
  const toggle = () => setIsOpen(prev => !prev);

  return (
    <CartContext.Provider value={{ items, isOpen, addItem, removeItem, toggle }}>
      {children}
    </CartContext.Provider>
  );
}

Every time isOpen toggles (like when the cart drawer opens), every component consuming this context re-renders — including your product cards that only care about addItem. This is wasteful and gets expensive at scale.

The fix: split your context by update frequency and concern.

// ✅ Split context
const CartStateContext = createContext(null);
const CartActionsContext = createContext(null);
const CartUIContext = createContext(null);

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

  // Memoize actions so their references stay stable
  const actions = useMemo(() => ({
    addItem: (item) => setItems(prev => [...prev, item]),
    removeItem: (id) => setItems(prev => prev.filter(i => i.id !== id)),
  }), []);

  const ui = useMemo(() => ({
    isOpen,
    toggle: () => setIsOpen(prev => !prev),
  }), [isOpen]);

  return (
    <CartActionsContext.Provider value={actions}>
      <CartUIContext.Provider value={ui}>
        <CartStateContext.Provider value={items}>
          {children}
        </CartStateContext.Provider>
      </CartUIContext.Provider>
    </CartActionsContext.Provider>
  );
}

// Each hook only subscribes to what it needs
export const useCartItems = () => useContext(CartStateContext);
export const useCartActions = () => useContext(CartActionsContext);
export const useCartUI = () => useContext(CartUIContext);

Now a product card that just needs addItem consumes useCartActions — it will never re-render when the cart drawer opens or closes.


Pattern 4: Multi-Level Context (Scope-Aware Providers)

Context isn't just for global, app-wide state. You can scope it to a subtree — and this is an underused, powerful pattern.

Imagine a DataGrid component. Each row needs to know its own row data and whether it's selected. You don't want this to live in global state. Instead, create row-scoped context:

const RowContext = createContext(null);

function DataGrid({ rows }) {
  return (
    <table>
      <tbody>
        {rows.map(row => (
          <RowContext.Provider key={row.id} value={row}>
            <TableRow />
          </RowContext.Provider>
        ))}
      </tbody>
    </table>
  );
}

function TableRow() {
  const row = useContext(RowContext);
  return (
    <tr>
      <td>{row.name}</td>
      <td>{row.status}</td>
      <ActionCell />
    </tr>
  );
}

function ActionCell() {
  const row = useContext(RowContext); // Gets the nearest Provider's value
  return <button onClick={() => handleAction(row.id)}>Edit</button>;
}

Each <RowContext.Provider> wraps its own row. Any component inside that row can reach up and grab that row's data without knowing where it lives in the component tree. This is the same mechanism that makes compound component patterns like <Select> / <Option> work in UI libraries.


Pattern 5: Context + useReducer (The Flux-Lite Pattern)

When your context state has multiple related fields and complex transitions, useState gets messy. useReducer pairs naturally with context to give you Redux-like clarity without the Redux overhead.

const initialState = {
  theme: 'light',
  language: 'en',
  notifications: true,
};

function settingsReducer(state, action) {
  switch (action.type) {
    case 'SET_THEME':
      return { ...state, theme: action.payload };
    case 'SET_LANGUAGE':
      return { ...state, language: action.payload };
    case 'TOGGLE_NOTIFICATIONS':
      return { ...state, notifications: !state.notifications };
    case 'RESET':
      return initialState;
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

const SettingsContext = createContext(null);

export function SettingsProvider({ children }) {
  const [settings, dispatch] = useReducer(settingsReducer, initialState);

  return (
    <SettingsContext.Provider value={{ settings, dispatch }}>
      {children}
    </SettingsContext.Provider>
  );
}

export function useSettings() {
  const ctx = useContext(SettingsContext);
  if (!ctx) throw new Error('useSettings must be used within SettingsProvider');
  return ctx;
}

Consumer usage is clear and intention-revealing:

function SettingsPanel() {
  const { settings, dispatch } = useSettings();

  return (
    <div>
      <select
        value={settings.theme}
        onChange={e => dispatch({ type: 'SET_THEME', payload: e.target.value })}
      >
        <option value="light">Light</option>
        <option value="dark">Dark</option>
      </select>

      <button onClick={() => dispatch({ type: 'TOGGLE_NOTIFICATIONS' })}>
        Notifications: {settings.notifications ? 'On' : 'Off'}
      </button>

      <button onClick={() => dispatch({ type: 'RESET' })}>
        Reset to defaults
      </button>
    </div>
  );
}

The reducer acts as a single source of truth for all state transitions. Each dispatch call is an explicit, named event — easy to trace, easy to test.


The Performance Reality Check

useContext does not bail out of re-renders. Unlike useState which skips re-renders when the value doesn't change, every context consumer re-renders whenever the Provider's value prop changes — even if the part of the value they use is the same.

The most common mistake is creating a new object reference on every render:

// ❌ New object on every render = every consumer re-renders every time
<AuthContext.Provider value={{ user, login, logout }}>

Fix it with useMemo:

// ✅ Stable reference unless user, login, or logout change
const value = useMemo(() => ({ user, login, logout }), [user, login, logout]);
<AuthContext.Provider value={value}>

For functions specifically, use useCallback:

const login = useCallback(async (credentials) => {
  const res = await authAPI(credentials);
  setUser(res.user);
}, []); // No deps — this never needs to change

When to Reach for Zustand or Redux Instead

Context is not a state management library. It's a dependency injection mechanism. The distinction matters:

Scenario Use Context Use Zustand/Redux
Auth state Overkill
Theme / locale Overkill
High-frequency updates (mouse pos, scroll)
Large, normalized server state ✅ (or React Query)
Deeply nested, frequently-mutated UI state ⚠️ Profile first
Sharing state between unrelated subtrees

Context re-renders all consumers on value change. This is fine when the data changes rarely (theme, auth, settings). It becomes a problem when dat a updates multiple times per second or when thousands of components are subscribed.


Putting It Together: A Real Feature

Here's what a complete, production-ready notification system looks like using everything above:

// context/NotificationContext.js
import { createContext, useContext, useReducer, useCallback } from 'react';

const NotificationContext = createContext(null);

function notificationReducer(state, action) {
  switch (action.type) {
    case 'ADD':
      return [...state, { id: Date.now(), ...action.payload }];
    case 'REMOVE':
      return state.filter(n => n.id !== action.id);
    case 'CLEAR':
      return [];
    default:
      return state;
  }
}

export function NotificationProvider({ children }) {
  const [notifications, dispatch] = useReducer(notificationReducer, []);

  const notify = useCallback((message, type = 'info') => {
    const id = Date.now();
    dispatch({ type: 'ADD', payload: { message, type } });

    // Auto-dismiss after 4s
    setTimeout(() => dispatch({ type: 'REMOVE', id }), 4000);
  }, []);

  const dismiss = useCallback((id) => {
    dispatch({ type: 'REMOVE', id });
  }, []);

  return (
    <NotificationContext.Provider value={{ notifications, notify, dismiss }}>
      {children}
      <NotificationToast />
    </NotificationContext.Provider>
  );
}

export function useNotification() {
  const ctx = useContext(NotificationContext);
  if (!ctx) throw new Error('useNotification must be used within NotificationProvider');
  return ctx;
}

// The UI layer lives inside the Provider itself
function NotificationToast() {
  const { notifications, dismiss } = useContext(NotificationContext);

  return (
    <div className="toast-container">
      {notifications.map(n => (
        <div key={n.id} className={`toast toast--${n.type}`}>
          <span>{n.message}</span>
          <button onClick={() => dismiss(n.id)}>×</button>
        </div>
      ))}
    </div>
  );
}

Anywhere in your app:

function SaveButton() {
  const { notify } = useNotification();

  const handleSave = async () => {
    try {
      await saveData();
      notify('Changes saved successfully', 'success');
    } catch {
      notify('Failed to save. Please try again.', 'error');
    }
  };

  return <button onClick={handleSave}>Save</button>;
}

No prop threading. No event emitters. No external libraries. Just context doing exactly what it was built for.


Key Takeaways

  • Always wrap useContext in a custom hook. It enforces provider boundaries, hides implementation details, and reads like your domain.

  • Split contexts by concern. State, actions, and UI state often belong in separate contexts when they update at different frequencies.

  • Memoize your context value. An inline object literal creates a new reference on every render. useMemo stops that.

  • Pair useReducer with context when state has complex transitions or more than two or three related fields.

  • Scope context to subtrees, not just the app root — it's a powerful pattern for compound components and reusable feature modules.

  • Know when to stop. Context is a delivery mechanism, not a substitute for Zustand, Redux, or React Query when you're dealing with high-frequency or server-synchronized state.

5 views

More from this blog