Skip to main content

Context API

Context provides a way to share data across the component tree without manually passing props at every level. It’s React’s built-in solution for “global” state. Real-world analogy: Imagine a building where the Wi-Fi password is posted in the lobby (the Provider). Any room (component) in the building can check the lobby sign directly — nobody needs to relay the password door-to-door from room to room. That relay process is prop drilling, and Context is the lobby sign that makes it unnecessary.

The Problem: Prop Drilling

Without Context, passing data to deeply nested components requires threading props through every intermediate component — even the ones that do not use the data themselves:
With Context, components can access shared data directly:

Creating and Using Context

Step 1: Create the Context

Step 2: Provide the Context

Step 3: Consume the Context


Complete Example: Theme Context

Here’s a full implementation with toggling:
Best Practice: Always create a custom hook (useTheme) instead of exporting the context directly. This:
  • Provides better error messages
  • Makes refactoring easier
  • Hides implementation details

Real-World Pattern: Auth Context

Using Auth Context


Multiple Contexts

You can compose multiple contexts:

Alternative: Compose Providers


Performance Optimization

The Re-render Problem

This is the biggest gotcha with Context and the reason senior developers sometimes avoid it for frequently-changing state. When context value changes, ALL consuming components re-render, even if they only use part of the context. There is no built-in way to subscribe to just a slice of the context value.

Solution 1: Split Contexts

Solution 2: Memoize Context Value

Solution 3: State and Dispatch Separation


Context with useReducer

For complex state, combine Context with useReducer:

Context Pitfalls

Pitfall 1 — Creating a new value object on every render: If your Provider creates the value object inline, every render creates a new reference, and all consumers re-render — even if the actual data has not changed.
Pitfall 2 — Using Context for frequently-changing state: Context is designed for state that changes infrequently (theme, locale, auth). If you put rapidly-changing state (mouse position, animation frames, typing input) into Context, every consumer re-renders on every change. For high-frequency updates, use useRef with a subscription pattern, or a library like Zustand or Jotai that supports fine-grained subscriptions.Pitfall 3 — Forgetting the Provider: If a component calls useContext(MyContext) but is not wrapped in a MyContext.Provider, it silently receives the default value from createContext(). If the default is undefined, you get cryptic “cannot read property of undefined” errors. Always use a custom hook with an error boundary: if (!context) throw new Error('useMyContext must be used within a MyProvider').Pitfall 4 — Nesting providers in the wrong order: If ContextA’s Provider needs data from ContextB, ContextB’s Provider must be the outer wrapper. Getting the order wrong means ContextA’s Provider tries to call useContext(ContextB) outside of ContextB’s Provider tree and gets the default value.

When to Use Context vs. Other Solutions


🎯 Practice Exercises


Summary

Next Steps

In the next chapter, you’ll learn about React Router — building multi-page applications with client-side routing!

Interview Deep-Dive

Strong Answer: This is the fundamental performance limitation of Context, and there is no built-in way to subscribe to a “slice” of context. When the Provider’s value changes (by reference), every component calling useContext for that context re-renders — even if it only reads theme and cart was the thing that changed.Three solutions, each with different tradeoffs:First, split contexts. Instead of one AppContext with theme and cart, create ThemeContext and CartContext. Components consuming only theme are unaffected by cart changes. This is the simplest and most recommended approach. The cost is more Provider components wrapping your app, but that has negligible runtime impact.Second, memoize the context value. Wrap the value object in useMemo: const value = useMemo(() => ({ theme, toggleTheme }), [theme]). This prevents re-renders when the provider component re-renders for unrelated reasons (like a parent state change), because the value reference stays stable. But it does NOT prevent re-renders when the actual context data changes — that is by design.Third, separate state and dispatch contexts. Inspired by Redux, you create two contexts: one for the state (which changes), one for the dispatch function (which is stable). Components that only dispatch actions (like an “Add to Cart” button) consume the dispatch context and never re-render when state changes. Components that read state consume the state context. This dramatically reduces re-renders in action-heavy UIs.What most people miss: you can also use React.memo on child components of the consumer to prevent their subtree from re-rendering. If CartBadge uses context but wraps its children in memo, the children are protected even when the badge re-renders.Follow-up: Some developers avoid Context for frequently changing values (like a real-time cursor position) and use Zustand or Jotai instead. Why?Context re-renders ALL consumers on every value change, and there is no granular subscription mechanism. For a cursor position updating at 60fps, every component in the context tree re-renders 60 times per second. Even with React.memo on most children, the consumers themselves still re-render and run their function bodies.Zustand and Jotai use external stores with selector-based subscriptions. A component subscribes to a specific slice of state: useStore(state => state.cursor). When cursor changes, only components that selected cursor re-render. Components that selected state.theme are completely untouched. This is possible because these libraries bypass React’s context mechanism and use useSyncExternalStore (React 18) to trigger granular re-renders.The practical threshold: if context value changes more than a few times per second or has many consumers (50+), switch to an external store. For infrequently changing values like theme, locale, and auth status, Context is perfectly adequate and simpler.
Strong Answer: Provider hell is when your root component has 10+ nested Providers, each wrapping the previous one. It looks like a pyramid of doom in JSX and makes the component tree hard to reason about in DevTools. The order of providers can matter (an AuthProvider that depends on an ApiProvider must be nested inside it), which creates implicit dependency chains.The simplest fix is a ComposeProviders utility component that takes an array of providers and nests them automatically:
This is cleaner but does not solve the underlying question: should you have this many contexts?For large applications, I evaluate each context: does it need to be global? Auth and theme are truly global. A shopping cart might be scoped to the e-commerce section. Form state should be local. Moving contexts to the lowest common ancestor reduces the blast radius of re-renders and makes the app structure clearer.Another approach: consolidate related contexts using useReducer. Instead of separate UserContext, PreferencesContext, and NotificationsContext, combine them into a single AppContext with a reducer that handles all three concerns. Split the state and dispatch into separate contexts for performance. This reduces Provider count while maintaining granular subscriptions.Follow-up: How do you test components that depend on multiple contexts?Create a test utility function that wraps components with the necessary providers pre-configured with test-friendly defaults:
This pattern (which React Testing Library’s docs recommend) lets each test specify only the overrides it cares about. For unit tests of a component that uses useAuth, you can mock the hook directly: vi.mock('./useAuth', () => ({ useAuth: () => ({ user: testUser }) })). This avoids setting up the full provider tree when you only need one context value.For integration tests, use the real providers with controlled initial state. This tests that the context wiring actually works end-to-end, catching bugs like a missing Provider or wrong context reference.
Strong Answer: Context plus useReducer is essentially a lightweight, built-in Redux. You get a central store (context value), actions (dispatched objects), a reducer (pure function), and subscriptions (useContext consumers). For small to medium apps, this is often all you need.Context stops being sufficient at three thresholds. First, performance: when you have many consumers and frequent state updates, Context’s all-or-nothing re-render behavior becomes a bottleneck. Redux’s useSelector with shallow equality checking only re-renders components whose selected slice changed. For a dashboard with 50 widgets reading different parts of global state, Redux prevents 49 unnecessary re-renders that Context would trigger.Second, middleware and side effects: Redux Toolkit’s createAsyncThunk and middleware pipeline give you a structured way to handle async logic, logging, error reporting, and optimistic updates. With Context, you manually implement all of this in your Provider or custom hooks. It is doable but messy at scale.Third, developer tools: Redux DevTools offer time-travel debugging, action replay, state diffs, and export/import of state snapshots. This is invaluable for debugging complex state transitions in production. Context has no equivalent tooling.My decision framework: start with local state. When multiple components need shared state, use Context. When you hit performance issues with Context or need more than 5-6 actions with complex async logic, evaluate Redux Toolkit or Zustand. For server state specifically (API data with caching, pagination, optimistic updates), skip Redux entirely and use React Query.Follow-up: Zustand has gained popularity over Redux for many teams. What makes it different and when would you still choose Redux?Zustand is dramatically simpler: no Providers, no wrapping your app, no actions/reducers pattern. You create a store with create(), define state and updaters as a plain object, and consume with a hook that supports selectors. The entire API is 5 functions. Bundle size is around 1KB.I would still choose Redux Toolkit when: the team is large and needs enforced patterns (actions, reducers, slices give structure that Zustand does not impose), when you need the DevTools time-travel debugging for complex state machines, when you have significant async logic that benefits from createAsyncThunk and middleware, or when the project already uses Redux and migration cost outweighs Zustand’s simplicity.I would choose Zustand when: the team is small, state logic is straightforward, bundle size matters (Zustand is 10x smaller than Redux Toolkit), and you value API simplicity over structural patterns. Zustand is particularly good for state that is not connected to React at all (like a game engine or a shared state between React and vanilla JS).