Session 9 — From Prop Drilling to Shared Global State
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.
Why passing props down five component levels is painful, and what "state management" actually means.
createContext, Provider, and useContext. Live theme switcher demo shows how any component in the tree can read shared state directly.
When state logic gets complex, useReducer brings structure. Dispatch actions, write predictable reducers, trace every state change.
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.
Decision guide for useState vs Context vs useReducer vs external libraries. Common mistakes and the quiz.
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?
Logical place — App is the root. But a <UserAvatar> five levels deep needs it too.
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.
Renaming the prop? Update five files. Adding a new field? Touch every component in the chain. This is prop drilling.
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.
// 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} />; }
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.
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.
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).
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.
Any component inside the Provider calls const user = useContext(UserContext). That's it — no prop needed, no coupling to parent components.
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; }
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 />; }
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
Context creates an "invisible channel" from the Provider down to any Consumer — skipping every component in between. Those middle components are completely untouched.
The dashed line is the context "tunnel" — no props pass through the middle components at all.
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).
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.
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.
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>; }
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.
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> ); }
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.
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.
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.
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> ); }
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.
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.
| Situation | Best tool | Why |
|---|---|---|
| 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 |
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.
Context is powerful but easy to misuse. Here are the four mistakes most developers make in their first week with it.
Putting user, theme, cart, notifications, and filters into a single context. Every consumer rerenders whenever any value changes — even unrelated ones.
Separate contexts for UserContext, ThemeContext, CartContext. A component consuming only ThemeContext won't rerender when the cart changes.
Storing a search query, scroll position, or mouse coordinates in context. Every keystroke triggers a rerender in every consumer — potentially 50+ components.
Search query stays in useState inside the search component. Only the committed result (after the user submits) goes into context or a parent.
// ✗ 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> ); }
// ✗ 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; }
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.
Passing data through intermediary components that don't need it. The pain is coupling — changes ripple through every level.
Creates a "tunnel" from a Provider to any Consumer in the tree. Use custom hooks to wrap it. Best for infrequently-changing global values.
Structured state with explicit actions. dispatch(action) → reducer(state, action) → new state. Predictable and testable.
The full global store pattern. Context carries state + dispatch. Any component can read or update — no props, no external library.
const { theme } = useContext(ThemeContext). When will this component rerender?useReducer a better choice than useState?value typically contain?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.