Context & State

Session 9 — From Prop Drilling to Shared Global State

60 minutes
🔗 Context API
Interactive
Scroll to begin
Part 1 · Overview

Session Agenda

We'll trace the full journey of state management in React — from a single useState, through the pain of prop drilling, all the way to a clean global state pattern with Context and useReducer.

0 – 10 min

The Problem — Prop Drilling

Why passing props down five component levels is painful, and what "state management" actually means.

10 – 25 min

useContext — React's Built-in Solution

createContext, Provider, and useContext. Live theme switcher demo shows how any component in the tree can read shared state directly.

25 – 40 min

useReducer — State with Actions

When state logic gets complex, useReducer brings structure. Dispatch actions, write predictable reducers, trace every state change.

40 – 55 min

Context + useReducer — The Full Pattern

Combine both hooks into a global store. Build a working shopping cart where any component can read and update the cart — without a single prop.

55 – 60 min

When to Use What + Pitfalls + Quiz

Decision guide for useState vs Context vs useReducer vs external libraries. Common mistakes and the quiz.

Part 2 · The Problem

Prop Drilling — When useState Isn't Enough

You already know useState. It's perfect when state lives in one component and maybe gets passed to a direct child. But what happens when a deeply nested component needs data that lives at the top of the tree?

1

You store the user object in App

Logical place — App is the root. But a <UserAvatar> five levels deep needs it too.

2

Every component in between must accept and forward it

Layout doesn't need user. Page doesn't need it. Section doesn't need it. But they all have to accept it as a prop just to pass it along.

3

The tree becomes tightly coupled and fragile

Renaming the prop? Update five files. Adding a new field? Touch every component in the chain. This is prop drilling.

Interactive — See the Difference

Toggle between approaches to see which components are affected. In the prop drilling scenario, every intermediate component is "infected" with a prop it doesn't use.

Show mode:
App user = {…}
Layout user={user}
Page user={user}
Section user={user}
UserAvatar ✦ uses user
Passes the prop (but doesn't need it)
prop-drilling.jsx — ⚠️ the painful pattern
// App — the only component that actually HAS the user
function App() {
  const [user, setUser] = useState({ name: 'Alice', role: 'admin' });
  return <Layout user={user} />;   // Layout doesn't need this…
}

// Layout — doesn't use user, but must accept + forward it
function Layout({ user }) {
  return <Page user={user} />;      // Page doesn't need it either…
}

// Page — same story
function Page({ user }) {
  return <Section user={user} />;
}

// Section — same story again
function Section({ user }) {
  return <UserAvatar user={user} />;
}

// UserAvatar — the ONLY component that actually needs user
function UserAvatar({ user }) {
  return <img src={user.avatar} alt={user.name} />;
}
⚠️ The Real Cost

Prop drilling isn't just annoying — it creates structural coupling. Every intermediate component now implicitly depends on the shape of user. Change that shape and TypeScript won't even warn you about the passing components until a runtime error.

Part 3 · The Solution

useContext — Share State Without Props

Context is React's built-in mechanism for making data available to any component in the tree, no matter how deep — without passing it as props through every level in between.

1

Create the context

Call createContext() once, outside any component. This creates a context object with a Provider component and a Consumer (you'll almost always use the hook instead).

2

Wrap the tree with a Provider

Place <UserContext.Provider value={user}> around the part of the component tree that needs access. Any component inside can now read the value — no matter how deep.

3

Read the value with useContext

Any component inside the Provider calls const user = useContext(UserContext). That's it — no prop needed, no coupling to parent components.

UserContext.jsx — Step 1: create
import { createContext, useContext, useState } from 'react';

// 1. Create the context (outside any component)
export const UserContext = createContext(null);

// 2. Create a Provider component (keeps concerns tidy)
export function UserProvider({ children }) {
  const [user, setUser] = useState({ name: 'Alice', role: 'admin' });

  return (
    <UserContext.Provider value={{ user, setUser }}>
      {children}
    </UserContext.Provider>
  );
}

// 3. Custom hook — cleaner API, avoids importing context everywhere
export function useUser() {
  const ctx = useContext(UserContext);
  if (!ctx) throw new Error('useUser must be inside <UserProvider>');
  return ctx;
}
App.jsx — Step 2: wrap the tree
import { UserProvider } from './UserContext';

function App() {
  return (
    {/* Wrap the part of the tree that needs the user */}
    <UserProvider>
      <Layout />   {/* No user prop needed here anymore */}
    </UserProvider>
  );
}

// Layout, Page, Section — completely clean, no user prop
function Layout() { return <Page />; }
function Page()   { return <Section />; }
function Section(){ return <UserAvatar />; }
UserAvatar.jsx — Step 3: consume anywhere
import { useUser } from './UserContext';

function UserAvatar() {
  const { user } = useUser();   // reads directly from context
  return <img src={user.avatar} alt={user.name} />;
}

// Any other deeply-nested component can do the same — zero prop drilling

How the Data Flows

Context creates an "invisible channel" from the Provider down to any Consumer — skipping every component in between. Those middle components are completely untouched.

<UserContext.Provider value={user}> — App
Layout
← skipped, untouched
Page
← skipped, untouched
Section
useContext(UserContext) — UserAvatar

The dashed line is the context "tunnel" — no props pass through the middle components at all.

💡 Always Create a Custom Hook

Wrap useContext in a custom hook like useUser(). Benefits: (1) you only import the hook, not the context object, (2) you can add a "must be inside Provider" error check, (3) the API is cleaner for consumers — useUser() is more readable than useContext(UserContext).

Part 4 · Live Demo

Context in Action — Theme & User

This demo simulates two separate contexts working together: a ThemeContext (dark/light) and a UserContext (current user). Click the buttons to dispatch changes through the context — every "component" in the mini-app reacts immediately without prop drilling.

React App — ThemeContext + UserContext running
ThemeContext: UserContext:
Alice
A
Dashboard
Role: admin
Analytics
View reports →
Settings
Theme: dark
Profile
alice@example.com
ThemeContext = { theme: "dark" }  ·  UserContext  = { name: "Alice", role: "admin" }

Below is the actual React code pattern this demo represents. Notice how Settings and Profile cards each consume context independently — they don't receive any props from the nav or dashboard.

ThemeContext.jsx — two contexts, one file
import { createContext, useContext, useState } from 'react';

const ThemeContext = createContext(null);
const UserContext  = createContext(null);

export function AppProviders({ children }) {
  const [theme, setTheme] = useState('dark');
  const [user, setUser]   = useState({ name: 'Alice', role: 'admin' });

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <UserContext.Provider value={{ user, setUser }}>
        {children}
      </UserContext.Provider>
    </ThemeContext.Provider>
  );
}

export const useTheme = () => useContext(ThemeContext);
export const useUser  = () => useContext(UserContext);

// In any component, anywhere in the tree:
function Settings() {
  const { theme, setTheme } = useTheme();
  return <button onClick={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}>Toggle</button>;
}
Part 5 · Complex State

useReducer — State with Structure

useState works great for a single value or a simple object. But when state transitions have rules — "you can't go below 0", "adding resets the error", "this action touches three fields at once" — you need useReducer.

Instead of setState(newValue), you dispatch an action. A pure reducer function receives the current state and the action, then returns the next state. The reducer is the only place state changes — making it easy to test, trace, and reason about.

useReducer data flow
Component
dispatch({
type:'INCREMENT'})
reducer(state, action)
switch(action.type) {
…return newState}
New state
{ count: 1,
step: 1 }
Re-render
Component
updates UI
counter-reducer.jsx
import { useReducer } from 'react';

// Initial state — a plain object
const initialState = { count: 0, step: 1 };

// Reducer — pure function, no side effects
function reducer(state, action) {
  switch (action.type) {
    case 'INCREMENT':
      return { ...state, count: state.count + state.step };
    case 'DECREMENT':
      return { ...state, count: state.count - state.step };
    case 'RESET':
      return initialState;
    case 'SET_STEP':
      return { ...state, step: action.payload };
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <div>
      <p>Count: {state.count} (step: {state.step})</p>
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
      <button onClick={() => dispatch({ type: 'DECREMENT' })}>−</button>
      <button onClick={() => dispatch({ type: 'RESET' })}>Reset</button>
      <button onClick={() => dispatch({ type: 'SET_STEP', payload: 5 })}>Set Step 5</button>
    </div>
  );
}

Interactive — Dispatch Actions Live

Click the action buttons on the right. Watch the state object update on the left, and see the exact action that was dispatched in the log below.

useReducer — live state inspector
Current State
{
  count: 0,
  step:  1
}
dispatch(action)
Dispatch an action above to see it logged here…
💡 useState vs useReducer — the rule of thumb

Use useState when the next state doesn't depend on the previous state in complex ways, and when there are 1-2 related values. Reach for useReducer when: (1) you have 3+ related state values, (2) the next state depends on the previous in non-trivial ways, or (3) multiple actions need to update state in coordinated ways.

Part 6 · Full Pattern

Context + useReducer — The Complete Pattern

Combining both hooks gives you a lightweight global store. Context carries the state and dispatch function through the tree. useReducer provides structured, predictable state updates. This pattern covers 90% of real-world state management needs without any external library.

CartContext.jsx — the full pattern
import { createContext, useContext, useReducer } from 'react';

const CartContext = createContext(null);

function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_TO_CART': {
      const existing = state.items.find(i => i.id === action.item.id);
      if (existing) {
        return { ...state, items: state.items.map(i =>
          i.id === action.item.id ? { ...i, qty: i.qty + 1 } : i
        )};
      }
      return { ...state, items: [...state.items, { ...action.item, qty: 1 }] };
    }
    case 'REMOVE_FROM_CART':
      return { ...state, items: state.items.filter(i => i.id !== action.id) };
    case 'CLEAR_CART':
      return { items: [] };
    default:
      return state;
  }
}

export function CartProvider({ children }) {
  const [state, dispatch] = useReducer(cartReducer, { items: [] });
  return (
    <CartContext.Provider value={{ state, dispatch }}>
      {children}
    </CartContext.Provider>
  );
}

// Custom hook — any component uses this
export function useCart() {
  return useContext(CartContext);
}

// NavBar — reads cart count (no props from parent)
function NavBar() {
  const { state } = useCart();
  return <span>🛒 {state.items.length}</span>;
}

// ProductCard — dispatches add action (no prop callbacks needed)
function ProductCard({ product }) {
  const { dispatch } = useCart();
  return (
    <button onClick={() => dispatch({ type: 'ADD_TO_CART', item: product })}>
      Add to cart
    </button>
  );
}

Interactive — Live Shopping Cart

Add products to the cart. Notice the cart icon in the nav updates — that's a completely separate component reading the same context, with no props connecting them.

CartContext + useReducer — live demo
NavBar reads useCart() →
🛒 0 items
Products — dispatch({ type: 'ADD_TO_CART' })
Cart State
Empty — add something!
Part 7 · Decision Guide

Choosing the Right Tool

State management is a spectrum. Start with the simplest solution and only move up when you feel the pain of the current one. Premature abstraction is the biggest mistake teams make.

useState

  • UI toggle (modal open/closed)
  • Form field value
  • Counter, tab selection
  • Any isolated local state
  • Data that 1 component owns

useContext

  • Theme (dark/light)
  • Logged-in user / auth state
  • Language / locale
  • Any "global" value that rarely changes
  • State that many components read

useReducer

  • Shopping cart (multi-step logic)
  • Form with many fields + validation
  • State machine (loading/error/success)
  • 3+ related values that change together
  • Undo/redo history patterns
SituationBest toolWhy
Modal is open or closed useState Single boolean, local to parent component
User's logged-in status needed by Header, Profile, Settings useContext Many consumers, rarely changes, global reach
Shopping cart with add/remove/clear/quantities useReducer + Context Complex transitions + multiple consumers
Input value in a search box useState Ephemeral UI state, changes fast, local
50+ components, server cache, optimistic updates External library Zustand, TanStack Query, or Redux Toolkit
🧠 When to reach for an external library

Context rerenders every consumer when the value changes. For a theme that changes once, that's fine. For a value that changes every keystroke (search input), it causes unnecessary renders. Libraries like Zustand add fine-grained subscriptions — components only rerender when the specific slice of state they read changes. Don't reach for a library before you feel the performance problem.

Part 8 · Common Mistakes

Context Pitfalls to Avoid

Context is powerful but easy to misuse. Here are the four mistakes most developers make in their first week with it.

✗ One giant context for everything

Putting user, theme, cart, notifications, and filters into a single context. Every consumer rerenders whenever any value changes — even unrelated ones.

✓ One context per concern

Separate contexts for UserContext, ThemeContext, CartContext. A component consuming only ThemeContext won't rerender when the cart changes.

✗ Putting fast-changing state in context

Storing a search query, scroll position, or mouse coordinates in context. Every keystroke triggers a rerender in every consumer — potentially 50+ components.

✓ Keep volatile state local

Search query stays in useState inside the search component. Only the committed result (after the user submits) goes into context or a parent.

pitfall-inline-object.jsx — ⚠️ common bug
// ✗ Bad — new object reference on every render → all consumers rerender
function BadProvider({ children }) {
  const [user, setUser] = useState(null);
  return (
    <UserContext.Provider value={{ user, setUser }}>   {/* new {} every render! */}
      {children}
    </UserContext.Provider>
  );
}

// ✓ Fix — useMemo so the object reference stays stable
import { useMemo } from 'react';
function GoodProvider({ children }) {
  const [user, setUser] = useState(null);
  const value = useMemo(() => ({ user, setUser }), [user]);
  return (
    <UserContext.Provider value={value}>
      {children}
    </UserContext.Provider>
  );
}
pitfall-missing-provider.jsx — ⚠️ silent null
// ✗ Bad — createContext(null) with no null check
const ThemeContext = createContext(null);

function Button() {
  const { theme } = useContext(ThemeContext); // crashes if no Provider above it
}

// ✓ Good — custom hook with a guard
function useTheme() {
  const ctx = useContext(ThemeContext);
  if (ctx === null) {
    throw new Error('useTheme must be used inside <ThemeProvider>');
  }
  return ctx;
}
💡 Not Everything is Prop Drilling

Passing a prop one or two levels is totally fine — it's explicit and easy to trace. Reach for Context when you're passing a prop through components that genuinely don't need it. The rule: if an intermediate component needs to know about the prop at all, it's not really drilling — it's legitimate composition.

Part 9 · Wrap-Up

Recap & Quick Quiz

🔗

Prop Drilling

Passing data through intermediary components that don't need it. The pain is coupling — changes ripple through every level.

🌐

useContext

Creates a "tunnel" from a Provider to any Consumer in the tree. Use custom hooks to wrap it. Best for infrequently-changing global values.

⚙️

useReducer

Structured state with explicit actions. dispatch(action)reducer(state, action) → new state. Predictable and testable.

🏪

Context + useReducer

The full global store pattern. Context carries state + dispatch. Any component can read or update — no props, no external library.

🧪 Quiz — Question 1 of 4

What is "prop drilling" in React?

🧪 Quiz — Question 2 of 4

A component calls const { theme } = useContext(ThemeContext). When will this component rerender?

🧪 Quiz — Question 3 of 4

When is useReducer a better choice than useState?

🧪 Quiz — Question 4 of 4

In the Context + useReducer pattern, what should the context value typically contain?
🎯 What's Next?

You now have a complete state management toolkit. In the next session we'll look at performance optimization — React.memo, useMemo, useCallback, and how to profile your app with the React DevTools to find and fix unnecessary rerenders.