Lesson 14 of 20

Context API

The Problem Context Solves

Props flow strictly downward, one level at a time. That is a strength — you can always tell where a value came from — but it becomes tedious when a value is needed far below where it lives. The signed-in user's name is held in App and needed by an avatar in the navbar, which sits inside a header, which sits inside a layout. Three components have to accept a user prop and pass it straight on, purely as a relay.

That is prop drilling. It is not a crime, and two levels of it is completely normal. It turns into a real problem when the same value is threaded through many branches: adding a second value means editing every one of those components again, and each of them now advertises a prop it does not use, which misleads anyone reading it.

Context lets a component put a value into the tree, and any component beneath it — at any depth — read that value directly. The relay components disappear from the picture entirely; they neither know nor care that the value exists.

Example
// Prop drilling: two components pass user along without using it
function App() {
  const [user, setUser] = useState(currentUser);
  return <Layout user={user} />;
}

function Layout({ user }) {
  return <Header user={user} />;      // does not use user
}

function Header({ user }) {
  return <Avatar user={user} />;      // does not use user either
}

function Avatar({ user }) {
  return <img src={user.photoUrl} alt={user.name} />;   // finally
}

// With Context, Layout and Header stop being involved at all.
Notes
  • Before reaching for Context, check whether composition solves it. If Layout accepted the finished header as children instead of building it, the user prop would never need to travel through Layout in the first place.

Create, Provide, Consume

Context has three parts and they always appear together. First, createContext() makes a context object — do this once, at module level, and export it. Second, a Provider wraps part of your tree and supplies a value: <ThemeContext.Provider value={theme}>. Third, any component inside that Provider calls useContext(ThemeContext) and receives the value.

The lookup rule is worth stating precisely, because it explains most Context confusion: useContext walks up the tree from the component that called it and takes the value from the nearest matching Provider above it. If there is no Provider above at all, it falls back to the default argument you passed to createContext().

That fallback is a trap in disguise. Forget to wrap your app in the Provider and nothing crashes — every component quietly receives the default value, and you get a theme that never changes or a user who is permanently undefined, with no error pointing at the cause. The next section fixes this properly.

One consequence of the nearest-Provider rule is genuinely useful: you can nest Providers. A dark-themed sidebar inside a light-themed page is just a second Provider wrapped around the sidebar.

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

// 1. Create — once, at the top level of a module
export const ThemeContext = createContext('light');   // 'light' is the fallback

// 2. Provide — wrap the part of the tree that should see the value
function App() {
  return (
    <ThemeContext.Provider value="dark">
      <Layout />
    </ThemeContext.Provider>
  );
}

// 3. Consume — at any depth, with no props in between
function Avatar() {
  const theme = useContext(ThemeContext);
  return <img className={`avatar avatar-${theme}`} src="/me.jpg" alt="" />;
}
  • createContext(defaultValue) — creates the context object
  • <Context.Provider value={...}> — supplies a value to everything inside
  • useContext(Context) — reads the nearest Provider's value
  • No Provider above? You silently get the default value
  • Providers can be nested; the nearest one wins
  • Good candidates: theme, signed-in user, language, cart, notifications
Notes
  • Context is not a way to skip rendering. Everything between the Provider and the consumer still renders normally — Context only changes how a value travels, not what gets rendered.

The Provider Component Pattern

A raw context value is rarely enough. Real shared data changes: the theme toggles, the user signs out, items go into the cart. So the standard pattern is to write a component that holds the state with useState and provides both the value and the functions that change it.

Everything you already know applies — the state lives in one place, updates are immutable, and children call the functions they were given. The only new part is that the value reaches consumers through Context instead of through props, so components at any depth can both read the cart and add to it.

Put the provider in its own file with the context object, and export both. Then main.jsx or App.jsx wraps the tree once and nothing else has to think about it. Keep the Provider as low in the tree as the data allows: a cart provider only needs to wrap the shop, not the login page.

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

const CartContext = createContext(null);

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

  function addItem(product) {
    setItems(prev => {
      const existing = prev.find(i => i.id === product.id);
      if (existing) {
        return prev.map(i => (i.id === product.id ? { ...i, qty: i.qty + 1 } : i));
      }
      return [...prev, { ...product, qty: 1 }];
    });
  }

  function removeItem(id) {
    setItems(prev => prev.filter(i => i.id !== id));
  }

  const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);

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

// src/main.jsx — wrap once
<CartProvider>
  <App />
</CartProvider>

// Any component, at any depth
function AddToCartButton({ product }) {
  const { addItem } = useCart();
  return <button onClick={() => addItem(product)}>Add to cart</button>;
}

function CartBadge() {
  const { items } = useCart();
  return <span>{items.length}</span>;
}
Notes
  • Notice that AddToCartButton and CartBadge may be on opposite sides of the page with no common ancestor other than App. That is precisely the case Context handles well and prop passing handles badly.

Wrap It in a Custom Hook

Rather than have every component import the context object and call useContext, export a small custom hook. It is three lines and it buys you two real things.

First, the import becomes one clean name — useCart() — and components never touch the context object at all. If you later replace Context with something else, you change one file rather than forty.

Second, and more valuable, it turns the missing-Provider failure into a loud, immediate error. Set the default value to null, then have the hook throw if it gets null back. Instead of a mysterious Cannot read properties of null somewhere deep in a component, you get a message naming the exact problem: this component is not inside a CartProvider. That single change turns a confusing half-hour into a five-second fix, and it is the reason most production code has hooks like this.

Example
// In CartContext.jsx, alongside the provider
export function useCart() {
  const context = useContext(CartContext);

  if (context === null) {
    throw new Error('useCart must be used inside a <CartProvider>');
  }

  return context;
}

// Every component now does this and nothing more
import { useCart } from '../context/CartContext';

function CartSummary() {
  const { items, total, removeItem } = useCart();

  if (items.length === 0) return <p>Your cart is empty.</p>;

  return (
    <ul>
      {items.map(item => (
        <li key={item.id}>
          {item.name} × {item.qty} — ₹{item.price * item.qty}
          <button onClick={() => removeItem(item.id)}>Remove</button>
        </li>
      ))}
      <li><strong>Total: ₹{total}</strong></li>
    </ul>
  );
}
Notes
  • If a component tree renders blank and the console says something was read from null or undefined, a missing Provider is a strong suspect — especially after moving a component to a different part of the app.

Everything Below Re-renders

When a Provider's value changes, every component that consumes that context re-renders. That is correct and necessary, but it has a consequence worth planning for: the more you put into one context, the more components re-render for changes they do not care about. A single AppContext holding the user, the theme, the cart and a notification list means that adding an item to the cart re-renders every component reading any of those.

There is also a subtler version of the same problem. Writing value={{ items, addItem }} creates a brand-new object on every render of the Provider — the contents are the same but the identity is not. Since consumers compare by identity, they all re-render whenever the Provider renders for any reason at all, not only when the data changed. If the Provider only ever re-renders because its own state changed, this costs nothing. If it re-renders for other reasons, wrap the value in useMemo.

The practical advice, in order: split large contexts into several focused ones, keep each Provider as low in the tree as it can go, and reach for useMemo when you have measured a problem. Do not start by memoising everything — most apps never need it, and the code is harder to read.

Example
import { useMemo, useCallback } from 'react';

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

  const addItem = useCallback((product) => {
    setItems(prev => [...prev, { ...product, qty: 1 }]);
  }, []);

  // Same object identity between renders, unless items actually changed
  const value = useMemo(
    () => ({ items, addItem, total: items.reduce((s, i) => s + i.price * i.qty, 0) }),
    [items, addItem]
  );

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

// Better still: separate contexts by how often they change
// <AuthProvider>      changes rarely
//   <ThemeProvider>   changes rarely
//     <CartProvider>  changes often
//       <App />
Notes
  • Components that do not read a context are unaffected by it changing. Only consumers re-render — so the cost of a busy context is proportional to how many components call its hook, not to how big your app is.

Context Is Not a State Manager

Context is a transport mechanism. It moves a value down the tree; it does not store anything, does not batch, does not cache, and has no opinions about how state should be structured. The state still lives in a useState inside your Provider. Saying we use Context for state management really means we keep state in a component near the top and pass it down through Context.

That is enough for a great many applications. A signed-in user, a theme, a language, a cart, a toast queue — all fine. It stops being enough when you have a large amount of frequently changing shared state, when you need fine-grained control over which components re-render, or when you want tooling such as time-travel debugging. That is where a dedicated library — Zustand, Jotai, Redux Toolkit — earns its keep.

The more common mistake, though, is reaching for Context too early. Before adding one, ask whether the state could simply live in a shared parent. If two components that need the same value have a common ancestor two levels up, lifting state up is less code, less indirection and easier to follow. Context is worth its complexity when the consumers are genuinely scattered.

  • Shared by two or three nearby components — lift state up instead
  • Needed almost everywhere and changes rarely — Context is a good fit
  • Server data with caching and refetching — TanStack Query or SWR, not Context
  • Large, fast-changing shared state — a dedicated state library
  • One context per concern, not one giant AppContext
  • Always pair a Provider with a use… hook that throws when the Provider is missing
Notes
  • When a Provider's logic grows past a few actions, useReducer is often a better fit than several useState calls — it keeps all the transitions for one piece of state in a single function. It is a built-in hook and worth reading about once you are comfortable with everything in this course.
Ask AI