Skip to main content

🌐 The useContext Hook

Week 5: Advanced React β€” course module banner illustration

Passing a value from the top of your component tree down to a deeply nested child by threading it through every component in between is tedious and brittle. useContext lets any component read shared data directly β€” no matter how deep it sits β€” turning "prop drilling" into a single, clean subscription.

Week 5 · Monday: React Hooks Deep Dive · Lecture 1

🎯 Learning Objectives

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

  • Recognize prop drilling and explain why it becomes a maintenance problem
  • Create a Context with createContext and expose it through a Provider component
  • Read context values in any descendant with the useContext hook
  • Wrap a context in a custom hook that guards against missing Providers
  • Decide what genuinely belongs in context versus ordinary props or local state
  • Anticipate Context re-render behavior and split contexts to keep it fast

Estimated Time: 60 minutes

Practice: Build a theme Provider plus a global shopping-cart context, each consumed through its own custom hook.

In This Lesson

The Problem: Prop Drilling

Imagine a busy pizza restaurant. The customer's order needs to reach the counter, the kitchen, and the delivery desk. Now imagine the only way to share the order was to hand a copy to every single person standing between the counter and the kitchen β€” even people who have nothing to do with it. That is prop drilling: passing a prop through layers of components that don't use it, purely to reach one that does.

Here's a theme value being forced down through components that only exist to relay it:

// Without context β€” prop drilling. Every layer must forward `theme`.
function App() {
  const [theme, setTheme] = useState('light');

  return (
    <div>
      <Navbar theme={theme} />      {/* Navbar doesn't use theme… */}
      <MainContent theme={theme} />
      <Footer theme={theme} />
    </div>
  );
}

function Navbar({ theme }) {
  // …it only forwards it downward.
  return (
    <nav>
      <Logo theme={theme} />
      <NavLinks theme={theme} />
    </nav>
  );
}

function NavLinks({ theme }) {
  return (
    <ul>
      <NavLink theme={theme} text="Home" />
      <NavLink theme={theme} text="About" />
    </ul>
  );
}

// NavLink is the ONLY component that actually reads `theme`.
graph TD A[App
owns theme state] --> B[Navbar] A --> C[MainContent] A --> D[Footer] B --> E[Logo] B --> F[NavLinks] F --> G[NavLink
finally uses theme] F --> H[NavLink
finally uses theme]

theme travels through Navbar and NavLinks, which never use it. Add a fourth or fifth layer and every one of them has to be edited whenever the shared data changes. This is exactly the kind of coupling that makes refactoring painful β€” and it is what context was designed to remove.

The Solution: Context & useContext

Context is like a warehouse with a public address. One component stocks the warehouse (the Provider), and any descendant can walk up and read from it directly (the consumer) β€” no relaying required. The data no longer passes through the components in between; it teleports straight to whoever asks.

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

// 1) Create the context. The argument is the default value used
//    ONLY when a component reads it with no Provider above it.
const ThemeContext = createContext(null);

// 2) A Provider component owns the state and shares it.
function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');

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

  // `value` is what every consumer receives.
  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

// 3) Any descendant consumes it β€” no props threaded through.
function NavLink({ text }) {
  const { theme } = useContext(ThemeContext);
  return <li className={`nav-link ${theme}`}>{text}</li>;
}

// 4) Wrap the tree once, at the top.
function App() {
  return (
    <ThemeProvider>
      <Navbar />
      <MainContent />
      <Footer />
    </ThemeProvider>
  );
}
A Provider at the top broadcasts a value that any descendant consumer reads directly ThemeProvider value = { theme, toggleTheme } Navbar MainContent Footer NavLink consumer CartButton consumer
Solid lines are the render tree. Dashed lines are the context "broadcast" β€” consumers subscribe directly to the Provider, skipping the layers in between.

Anatomy of a Context

A context has exactly three moving parts. Keep them straight and the rest is easy.

PieceWhat it isWhere it lives
createContext(default)The channel itself; returns an object with a .ProviderModule top level, exported
<Ctx.Provider value={…}>Broadcasts value to every descendantHigh in the tree, wrapping children
useContext(Ctx)Reads the nearest Provider's valueInside any descendant component

⚠️ The default value is a fallback, not the norm

The argument to createContext is used only when a component calls useContext with no matching Provider above it. In a correctly wrapped app you'll almost never hit it β€” which is why passing null and throwing a helpful error (next section) beats a silent, misleading default.

Wrapping Context in a Custom Hook

Calling useContext(ThemeContext) everywhere means importing the context object into every file and hoping a Provider exists above you. A tiny custom hook fixes both problems: it hides the context object and fails loudly if you forgot the Provider.

// A custom hook is just a function starting with "use"
// that calls other hooks. This one guards the context.
function useTheme() {
  const context = useContext(ThemeContext);
  if (context === null) {
    throw new Error('useTheme must be used inside a <ThemeProvider>');
  }
  return context;
}

// Consumers never touch ThemeContext directly:
function ThemeToggle() {
  const { theme, toggleTheme } = useTheme();
  return (
    <button onClick={toggleTheme}>
      {theme === 'light' ? 'πŸŒ™ Dark' : '🌞 Light'} mode
    </button>
  );
}

βœ… Why this pattern is standard

The custom-hook wrapper gives you one import (useTheme) instead of two (useContext + ThemeContext), a clear error the moment a Provider is missing, and a single place to add logic later. You'll see this exact shape in nearly every production React codebase.

Real-World Example: A Shopping Cart

Let's build a global cart that any product card or header badge can reach. We combine context (for sharing) with useReducer (for structured updates β€” the subject of the next lesson). Don't worry about the reducer details yet; focus on how the cart is shared.

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

const CartContext = createContext(null);

function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM': {
      const existing = state.items.find(i => i.id === action.payload.id);
      if (existing) {
        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
        ),
      };
    default:
      return state;
  }
}

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

  // Derived value β€” recomputed on each render, never stored in state.
  const cartTotal = state.items.reduce(
    (total, item) => total + item.price * item.quantity,
    0
  );

  const value = {
    items: state.items,
    cartTotal,
    addToCart: (product) => dispatch({ type: 'ADD_ITEM', payload: product }),
    removeFromCart: (id) => dispatch({ type: 'REMOVE_ITEM', payload: id }),
    updateQuantity: (id, quantity) =>
      dispatch({ type: 'UPDATE_QUANTITY', payload: { id, quantity } }),
  };

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

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

Now any component β€” however deep β€” reads or updates the cart in one line:

function Product({ product }) {
  const { addToCart } = useCart();
  return (
    <div className="product-card">
      <h3>{product.name}</h3>
      <p>${product.price.toFixed(2)}</p>
      <button onClick={() => addToCart(product)}>Add to Cart</button>
    </div>
  );
}

function CartIcon() {
  const { items } = useCart();
  const count = items.reduce((sum, item) => sum + item.quantity, 0);
  return (
    <div className="cart-icon">
      πŸ›’ {count > 0 && <span className="badge">{count}</span>}
    </div>
  );
}

πŸ’‘ Notice what we did not store

cartTotal and count are derived from items on every render. Storing them in state would risk them drifting out of sync. Rule: keep the minimal source of truth in state, compute the rest.

Common Context Patterns

Authentication context

Auth state (the current user plus login/logout) is the textbook use for context β€” nearly every screen needs it.

const AuthContext = createContext(null);

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

  useEffect(() => {
    const token = localStorage.getItem('token');
    if (!token) { setLoading(false); return; }
    fetchUser(token)
      .then(setUser)
      .finally(() => setLoading(false));
  }, []);

  const login = async (email, password) => {
    const res = await authAPI.login(email, password);
    setUser(res.user);
    localStorage.setItem('token', res.token);
  };

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

  if (loading) return <LoadingSpinner />;

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

Internationalization (i18n)

A language context exposes a t() translator that any component can call.

const LanguageContext = createContext(null);

const translations = {
  en: { welcome: 'Welcome', addToCart: 'Add to Cart' },
  es: { welcome: 'Bienvenido', addToCart: 'AΓ±adir al carrito' },
};

function LanguageProvider({ children }) {
  const [language, setLanguage] = useState('en');
  // Look up the key; fall back to the key itself if missing.
  const t = (key) => translations[language][key] ?? key;

  return (
    <LanguageContext.Provider value={{ language, setLanguage, t }}>
      {children}
    </LanguageContext.Provider>
  );
}

function WelcomeMessage() {
  const { t } = useContext(LanguageContext);
  return <h1>{t('welcome')}!</h1>;
}

Re-renders & Performance

Context is powerful, but it has one behavior you must respect: when a Provider's value changes, every consumer re-renders β€” even consumers that only read a part of the value they didn't change.

Two habits keep this fast:

⚠️ 1. Don't create a fresh object literal every render for stable data

// ❌ A NEW object every render β†’ all consumers re-render every time
<ThemeContext.Provider value={{ theme, toggleTheme }}>

// βœ… Memoize when the value is expensive to recompute or widely consumed
const value = useMemo(() => ({ theme, toggleTheme }), [theme]);
<ThemeContext.Provider value={value}>

For a small app the object literal is fine. For a large tree with many consumers, useMemo avoids needless re-renders.

βœ… 2. Split one big context into focused ones

// ❌ One mega-context: a theme flip re-renders cart consumers too
const AppContext = createContext({ user, theme, cart, notifications });

// βœ… Focused contexts: each consumer only re-renders for its own slice
const UserContext = createContext(null);
const ThemeContext = createContext(null);
const CartContext = createContext(null);

A component that only reads ThemeContext won't re-render when the cart changes. Separation of concerns doubles as a performance win.

Practice & Quiz

πŸ‹οΈ Exercise 1: A guarded theme hook

Goal: Complete a ThemeProvider and a useTheme() custom hook that throws if used outside the Provider.

const ThemeContext = createContext(null);

function ThemeProvider({ children }) {
  // TODO: hold theme state, expose { theme, toggleTheme }
}

function useTheme() {
  // TODO: read context, throw if null, otherwise return it
}
πŸ’‘ Hint

Use useState('light') for the theme and a toggleTheme that flips it with the functional updater. In useTheme, read with useContext, compare to null, then throw new Error(...).

βœ… Solution
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>
  );
}

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

πŸ‹οΈ Exercise 2: A notification context

Goal: Build a NotificationProvider that lets any component call notify('Saved!'), stores toasts in an array, and auto-removes each one after 3 seconds.

βœ… Solution
const NotificationContext = createContext(null);

function NotificationProvider({ children }) {
  const [toasts, setToasts] = useState([]);

  const notify = (message) => {
    const id = Date.now();
    setToasts(prev => [...prev, { id, message }]);
    // Auto-dismiss after 3s
    setTimeout(() => {
      setToasts(prev => prev.filter(t => t.id !== id));
    }, 3000);
  };

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

export function useNotify() {
  const ctx = useContext(NotificationContext);
  if (!ctx) throw new Error('useNotify needs a NotificationProvider');
  return ctx;
}

🎯 Quick Quiz

Question 1: What problem does useContext primarily solve?

Question 2: When is the argument passed to createContext(defaultValue) actually used?

Question 3: Why split one large context into several focused ones?

Best Practices & Pitfalls

βœ… Do

  • Wrap each context in a guarded custom hook (useCart, useAuth)
  • Keep the minimal source of truth in state and derive the rest
  • Split contexts by concern β€” auth, theme, cart β€” for clarity and fewer re-renders
  • Reserve context for truly app-wide data: auth, theme, language, cart

❌ Don't

  • Put fast-changing, local UI state (a single input's value) in context
  • Bundle everything into one mega-context that re-renders the whole tree
  • Rely on the createContext default instead of a real Provider
  • Forget that changing the Provider value re-renders all consumers

⚠️ Context is not a state manager

Context is a transport mechanism β€” it moves a value from a Provider to consumers. The state itself still comes from useState or useReducer. For very large apps with heavy cross-cutting updates, a dedicated library (Redux Toolkit, Zustand) may serve better; context shines for moderate, app-wide data.

Summary

πŸŽ‰ Key Takeaways

  • Prop drilling β€” threading props through components that don't use them β€” is what context removes
  • A context has three parts: createContext, a Provider, and useContext
  • Wrap each context in a guarded custom hook so a missing Provider fails loudly
  • Derive values like totals from state instead of storing them
  • Changing a Provider's value re-renders every consumer β€” memoize and split contexts to stay fast

πŸ“š Additional Resources

πŸš€ What's Next?

Our cart already leaned on a reducer to structure its updates. Next we slow down and master that tool on its own: the useReducer hook β€” dispatching actions to a pure reducer for predictable, complex state.

πŸŽ‰ Great work!

You can now share state across an entire app without a single drilled prop. That's the backbone of every real React project.