React – Passing Data Deeply with Context

July 12, 20266 min readUpdated 8/18/2026

Props flow down one level at a time. When a value is needed six levels down, every component in between has to accept it and pass it on, whether or not it cares. That is prop drilling, and context is the way out.

The problem, concretely

The cart badge in the navbar needs the number of items in the cart. So does the cart drawer, the menu page, and checkout. Those components are nowhere near each other:

App
├── AppNavbar          ← needs the item count
├── Routes
│   ├── MenuPage
│   │   └── PizzaBuilderModal   ← needs to ADD to the cart
│   └── CheckoutPage            ← needs the whole cart
└── CartDrawer                  ← needs the whole cart

Without context, the cart would have to live in App and be passed to every one of those, through Routes and any other component in the way. Adding a field to the cart would mean editing five files that do not use it.

Three pieces

Context is always the same three things: create it, provide it, consume it.

// 1. Create — outside any component, usually in its own file.
const CartContext = createContext<CartContextValue | undefined>(undefined);

// 2. Provide — a component that owns the state and publishes it.
export function CartProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(cartReducer, initialState);
  const value = { /* … */ };
  return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}

// 3. Consume — anywhere below the provider.
const { totals } = useContext(CartContext);

The provider is mounted once, near the top:

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <BrowserRouter>
      <AuthProvider>
        <MenuProvider>
          <CartProvider>
            <ToastProvider>
              <App />
            </ToastProvider>
          </CartProvider>
        </MenuProvider>
      </AuthProvider>
    </BrowserRouter>
  </StrictMode>,
);

Now the navbar reads the cart directly, no matter how deep it sits:

export function AppNavbar({ onOpenCart }: { onOpenCart: () => void }) {
  const { totals } = useCart();
  const { user, isAuthenticated, isAdmin, logout } = useAuth();

  return (
    /* … */
    <Button variant="primary" onClick={onOpenCart} aria-label={`Open cart, ${totals.itemCount} items`}>
      Cart
      {totals.itemCount > 0 && (
        <Badge bg="light" text="dark" pill className="cart-badge">
          {totals.itemCount}
        </Badge>
      )}
    </Button>
  );
}

The undefined default, and why

createContext takes a default, used when a component reads the context with no provider above it. It is tempting to supply a sensible-looking empty cart. Do not:

// Tempting, and wrong: a component outside the provider silently gets an empty cart that
// never updates, and you debug the symptom instead of the cause.
const CartContext = createContext<CartContextValue>({ items: [], totals: emptyTotals, /* … */ });

// `undefined` as the default is deliberate: it lets the useCart hook below detect a component
// rendered outside the provider and throw a clear error.
const CartContext = createContext<CartContextValue | undefined>(undefined);

Then the guard lives in one place, and the error names the actual problem:

export function useCart(): CartContextValue {
  const context = useContext(CartContext);
  if (context === undefined) {
    throw new Error('useCart must be used inside a <CartProvider>');
  }
  return context;
}

That wrapper is a custom hook, and it does three jobs at once: it hides the context object so nothing else imports it, it removes the | undefined from the type so consumers need no null check, and it produces a useful message instead of "cannot read properties of undefined". Write it every time.

The provider owns the state

A context does not hold state — it distributes it. Something has to own it, and that is the provider component. Here is the menu one, which fetches from the API and shares the result:

export function MenuProvider({ children }: { children: ReactNode }) {
  const [products, setProducts] = useState<Product[]>([]);
  const [toppings, setToppings] = useState<Topping[]>([]);
  const [crusts, setCrusts] = useState<Crust[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [reloadToken, setReloadToken] = useState(0);

  const reload = useCallback(() => setReloadToken((n) => n + 1), []);

  useEffect(() => {
    /* … fetch products, toppings and crusts … */
  }, [reloadToken]);

  // Derived lists, recomputed only when products actually change.
  const pizzas = useMemo(() => products.filter((p) => p.type === 'PIZZA'), [products]);
  const drinks = useMemo(() => products.filter((p) => p.type === 'DRINK'), [products]);

  const value = useMemo<MenuContextValue>(
    () => ({ products, pizzas, drinks, toppings, crusts, loading, error, reload }),
    [products, pizzas, drinks, toppings, crusts, loading, error, reload],
  );

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

The value being shared is not just data — it includes loading, error and a reload function. That is the point: the provider publishes a small API, and consumers never know there is an HTTP request behind it.

It also means the menu is fetched once. Three components mounting would otherwise mean three identical round trips.

Split contexts by how often they change

This app has four providers rather than one, and that is deliberate:

ContextHoldsChanges
AuthContextthe signed-in userrarely — sign in, sign out
MenuContextthe catalogueonce, on load
CartContextthe basketconstantly
ToastContextnotificationsconstantly, briefly

Every component reading a context re-renders when that context's value changes. One big AppContext would mean every cart click re-rendering everything that only cares who is signed in. Splitting by update frequency is the standard fix, and it costs nothing.

The re-render trap

Here is the mistake that makes people conclude context is slow:

// WRONG. A brand-new object every render, so every consumer re-renders every time —
// even when nothing they read has changed.
return (
  <CartContext.Provider value={{ items, totals, addItem, removeItem }}>
    {children}
  </CartContext.Provider>
);

Context compares the value by reference. An object literal in the JSX is a new reference on every render of the provider, so every consumer sees a change. The fix is useMemo around the value, and useCallback around each function in it:

const addItem = useCallback((input: AddItemInput) => {
  dispatch({ type: 'ADD_ITEM', payload: { /* … */ } });
}, []);

const removeItem = useCallback((lineId: string) => {
  dispatch({ type: 'REMOVE_ITEM', payload: { lineId } });
}, []);

const value = useMemo<CartContextValue>(
  () => ({
    items: state.items,
    orderType: state.orderType,
    totals: calculateTotals(state.items, state.orderType),
    addItem,
    removeItem,
    setQuantity,
    setOrderType,
    clear,
    hydrated,
  }),
  [state.items, state.orderType, addItem, removeItem, setQuantity, setOrderType, clear, hydrated],
);

The useCallback calls have empty dependency arrays because they only ever call dispatch, which React guarantees is stable. That is one of the quieter advantages of useReducer over useState in a provider.

One provider consuming another

Providers can use each other's contexts, and then nesting order stops being cosmetic. CartProvider calls useMenu() — a saved cart line stores only ids, so re-pricing it needs the catalogue:

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

  // The catalogue is needed to rebuild a saved line's base price and crust surcharge.
  const { products, crusts, loading: menuLoading } = useMenu();
  /* … */
}

So CartProvider must be inside MenuProvider. Swap them and the app throws "useMenu must be used inside a <MenuProvider>" at startup — which, thanks to the guard, tells you precisely what is wrong.

What context is not for

Not everything shared belongs in context. Whether the cart drawer is open is needed by exactly two components, and it is lifted into App as ordinary state instead:

// The drawer's open/closed state lives here because both the navbar (which opens it) and the
// drawer itself need it. The cart CONTENTS are in context; this piece of purely-visual state
// is not worth putting there.
const [cartOpen, setCartOpen] = useState(false);

Reach for context when passing a prop down would mean threading it through components that do not use it. Two levels is not prop drilling; it is just props.

Context is also not a data-fetching library. MenuContext does it by hand because doing it once by hand is worth seeing — but caching, retries, revalidation and deduplication are what TanStack Query exists for, and past a certain point you want that rather than more providers.

Next

useReducer — how the cart provider manages its state.