Free lesson · React Deep Dive
When Context Is the Answer (and When It Isn't)
Lesson 18.1 - When Context Is the Answer (and When It Isn't)
The lesson that revisits Context for the state-management question.
Context was introduced in M10.1 as a mechanism for passing values down a tree without prop drilling. M16.4 covered its performance characteristics - the cascade of re-renders that happens when Context values change. This lesson covers Context specifically in the state-management framing: for what kinds of state is Context the right tool, and for what kinds is it the wrong one?
The framing for the lesson: Context is a solution to a specific problem - distributing values down a tree. It's not a state management library, and treating it as one produces specific kinds of pain. That said, for many apps, the state-distribution problem is exactly what Context solves. Knowing when Context is enough is the first step in the state-management decision.
By the end, you'll have a working framework for "is Context enough, or do I need something else?" - a question that should always precede "which library should I use?"
What Context Actually Is
A reminder. The React Context API provides three things:
- A way to create a Context object (
createContext). - A Provider component that supplies a value to its descendants.
- A way to consume the value (
useContext).
That's it. It's a value-distribution mechanism. It does not provide:
- A subscription mechanism for fine-grained updates.
- A way to update state without re-rendering all consumers.
- A way to query subsets of the value efficiently.
- Built-in time-travel debugging or persistence.
- An async middleware layer.
Context distributes values. When the value changes (by reference), every consumer re-renders. That's the contract.
This contract is exactly what some apps need. For other apps, it's not enough - they need the things in the second list. The question is which app you have.
What Context Is Good For
A specific list of cases where Context shines:
1. Configuration values that rarely change. Theme, locale, feature flags, environment configs. These change once at app startup or rarely after; the cascade on change isn't a problem because change is rare.
2. Auth state. Current user, permissions, session tokens. The data is referenced widely but doesn't change frequently (login, logout, occasional refresh). The cascade is acceptable.
3. UI shell state. Sidebar open/closed, current breakpoint, color scheme. Used by many components; changes are moderate.
4. Domain-bounded "session" state. A wizard's current step, a form's mode, a multi-step flow's data. Scoped to a section of the app where the consumer count is small and updates are user-initiated.
5. Composition contexts. Compound components from M9.5 (Tabs, Accordions). The Context is internal to the component, never touched outside it. The cascade is among a small known set of children.
For these cases, Context is exactly the right tool. Reaching for a library is over-engineering. The complexity of a library doesn't earn its keep when Context handles the case cleanly.
What Context Is Bad For
The complementary list. Cases where Context produces pain:
1. State that changes frequently with many consumers. Mouse position, scroll position, anything tracking fast events. Every change re-renders every consumer. With 50+ consumers, this becomes a performance issue.
2. State with complex update logic. Updates that involve multiple sub-states, derived calculations, or atomic operations across multiple fields. Context only gives you "set the whole value"; complex update logic has to be reimplemented every time.
3. State that needs derived selectors. "Give me the count of active users from this user list." Context returns the whole value; you derive in the consumer. For many consumers each computing their own derivations, you're either duplicating work or building a custom selector layer.
4. State that needs middleware - logging, persistence, undo/redo, network sync. Context has no middleware concept. You'd build one yourself.
5. State that needs to update outside the React tree. Background tasks, web workers, web socket handlers updating shared state. Context lives inside the React tree; reaching it from outside is awkward.
6. State that benefits from time-travel debugging. Step through past states to debug. Context doesn't have this.
These are the signals that a state-management library is justified. The library exists to address one or more of these limitations.
The "Context as State Manager" Antipattern
A specific pattern worth flagging. Engineers see Context and think "I can use this for all my app state." They build a single AppContext with everything in it - user, theme, current page, form data, cached results, UI flags. Then they wonder why performance is bad and updates are confusing.
// ❌ Anti-pattern - single context for everything
const AppContext = createContext({
user: null,
theme: 'light',
notifications: [],
cart: {},
search: { query: '', results: [], loading: false },
// ... 20 more fields
})
The problems compound:
- Every change to any field re-renders every consumer (the cascade from M16.4).
- The update logic for unrelated fields gets tangled in the same Provider.
- Adding a field means touching a central Context (concurrent commits, merge pain).
- Testing one piece in isolation is hard because it's coupled to the whole.
For an app with one moderate-sized state slice, Context is fine. For an app with twelve independent slices, Context as the single store is the wrong tool. Either split into many small contexts (each with its own scope) or use a library that handles many-slices natively.
This is the antipattern that makes engineers say "Context is bad for state management." Context isn't bad; using it as a single global store is bad. Used scope-by-scope, Context works well.
The Decision
flowchart TB
Start["State to manage"]
Q1{"Does it change<br/>frequently with<br/>many consumers?"}
Q2{"Complex updates<br/>(multi-field,<br/>derived, atomic)?"}
Q3{"Need middleware<br/>(persistence,<br/>undo, logging)?"}
Q4{"Access from outside<br/>the React tree?"}
Q5{"Time-travel debugging<br/>or scrubbing<br/>past states?"}
Context["Context is enough"]
Library["Use a library<br/>(see 18.3-18.5)"]
Start --> Q1
Q1 -->|"no"| Q2
Q1 -->|"yes"| Library
Q2 -->|"no"| Q3
Q2 -->|"yes"| Library
Q3 -->|"no"| Q4
Q3 -->|"yes"| Library
Q4 -->|"no"| Q5
Q4 -->|"yes"| Library
Q5 -->|"no"| Context
Q5 -->|"yes"| Library
style Context fill:#34d399,stroke:#10b981,color:#000
style Library fill:#fbbf24,stroke:#f59e0b,color:#000
A decision flowchart for whether Context is enough or whether a state management library is justified. Start with a piece of state you need to manage. The first check: does it change frequently with many consumers? Mouse position with 50 consumers is a yes. A theme setting with 200 consumers but that changes once a session is a no. If yes, the cascade rule makes Context expensive; use a library. Second check: are the updates complex - multi-field, derived, atomic? Updating one field while keeping invariants with others, or computing derived values that consumers should see consistently. If yes, Context's "set the whole value" doesn't fit; use a library. Third check: do you need middleware like persistence, undo, logging? Context has no middleware concept. Fourth check: do you need to access the state from outside the React tree, like a web worker or a background handler? Context lives in the React tree. Fifth check: do you need time-travel debugging or the ability to scrub past states for development? If you've answered no to all five, Context is enough. If any answer is yes, a library is justified - and the next lessons cover which.
Walk through this flowchart with a specific case. Say you're building a settings panel with theme, font size, and language preferences. Three checks:
- Frequent updates with many consumers? No - settings change infrequently.
- Complex updates? No - each setting is independent.
- Middleware needed? Maybe - you want persistence. But that's localStorage, not a library requirement.
- External access? No - the React app is the only consumer.
- Time travel? No.
The settings panel doesn't need a library. Context with a persistence effect (or useLocalStorage from M12.3) is enough. Reaching for Redux Toolkit here is over-engineering.
Now consider a different case: a collaborative document editor with real-time sync.
- Frequent updates? Yes - every keystroke from any user.
- Complex updates? Yes - operational transforms, conflict resolution.
- Middleware? Yes - websocket sync, persistence to server, undo/redo.
- External access? Yes - websocket handlers update state.
- Time travel? Yes - undo/redo specifically.
Five yeses. Context isn't going to handle this; a real library is needed. (And probably a specialized one - collaborative editing is its own discipline.)
The middle cases are the interesting ones. A medium-sized app with some complexity. The flowchart should help you reason about them.
The "Multiple Contexts" Pattern
Before reaching for a library, consider whether multiple contexts solve your problem. This was introduced briefly in M10.1; the state-management framing is worth restating.
Instead of one big Context:
// ❌ One context with everything
const AppContext = createContext({ user, theme, cart, search, ... })
Use multiple small contexts:
// ✅ Separate contexts, separate concerns
const UserContext = createContext(null)
const ThemeContext = createContext('light')
const CartContext = createContext({ items: [], total: 0 })
const SearchContext = createContext({ query: '', results: [] })
Each context has its own Provider, its own update mechanism, its own consumers. Components only consume the contexts they care about. Updates to one context don't cascade to consumers of others.
This pattern handles many cases that look like "I need state management." The composition of small contexts is often enough; reaching for a library is unnecessary.
The trade-offs:
- Multiple Providers in the tree (nesting can get deep for many contexts).
- Some boilerplate per context.
- No built-in selector layer - consumers see the whole context value.
For 3-10 separate concerns, multiple contexts is fine. For 20+ concerns, the boilerplate starts adding up, and a library becomes preferable.
Performance Considerations Recap
A condensation of M16.4 + M17.1's relevant points:
The cascade. Every Context value change re-renders every consumer. For Context values that change frequently, this is the dominant cost.
Reference stability matters. Providers with inline value={{ ... }} create new references each render, triggering consumer re-renders even when nothing meaningful changed. Use useMemo:
const value = useMemo(() => ({ user, setUser }), [user])
<UserContext.Provider value={value}>
The two-Context pattern. For state + dispatch separation (covered properly in 18.2), split the state value (changes often) from the dispatch function (stable forever). Consumers that only need to dispatch don't re-render on state changes.
React.memo doesn't help. Consumers re-render because they're subscribed via useContext, not because of prop changes. React.memo is irrelevant for context-driven re-renders.
For most Context use, these aren't issues - the value changes infrequently and the cascade is acceptable. For Context use that approaches state-management territory, these patterns become important. We'll cover them in 18.2.
The "Just Use Context" Confusion
A specific point worth being direct about. Some engineering content frames "use Context for state management" as universally correct advice - "you don't need Redux; just use Context." This is wrong as universal advice. Context is sometimes enough; sometimes not. The honest framing is "evaluate whether Context is enough; if it is, don't reach for a library; if it isn't, reach for the right library."
The opposite framing - "always use Redux for any non-trivial state" - is also wrong. It produces over-engineering for the cases where Context would have sufficed.
The framework in this lesson is the honest middle ground. Evaluate the case; pick the right tool. Don't default to one extreme or the other.
A Specific Case: SSR and Hydration
A consideration that affects the Context decision in server-rendered apps. Context values established during server rendering need to match values established during client hydration; mismatches produce hydration warnings.
For Context-managed state (theme from a cookie, user from session, etc.), the pattern is to thread the initial value from the server to the client via:
- A serialized payload (Next.js
getServerSidePropsor RSC). - A cookie read on the server, set as initial state on the client.
- A meta tag with serialized data.
This is doable with Context. It's also doable with libraries. For SSR-heavy apps, the data flow is usually framework-driven (Next.js, Remix) and the state-management choice slots in alongside.
For client-only apps (no SSR), this consideration doesn't apply. Pick whichever tool fits the case.
What I Want You to Take From This Lesson
Six things.
One: Context is a value-distribution mechanism, not a state-management library. Treating it as one produces specific pain.
Two: Context is good for configuration, auth, UI shell state, scoped session state, composition contexts.
Three: Context is bad for frequent updates with many consumers, complex updates, middleware needs, external-tree access, time-travel debugging.
Four: The "single AppContext with everything" antipattern is what makes engineers say "Context is bad for state."
Five: Multiple small contexts often handle cases that look like "I need state management."
Six: Reach for a library when the Context limits are actually limiting you, not preemptively.
Try It
Exercise: Warmup (20 minutes). Audit a project for Context usage. For each context, identify what kind of state it holds. Map each to the categories in this lesson - good fit, bad fit, or borderline.
Exercise: Core (25 minutes). Take a piece of state currently managed in a single big Context. Refactor into multiple smaller contexts by concern. Verify the cascade is reduced.
Exercise: Core (30 minutes). Walk through the flowchart for three pieces of state in your project. Decide for each whether Context is enough. Document the reasoning.
Exercise: Stretch (45 minutes). Find an existing piece of state that's in a library (Redux, Zustand) but could be in Context. Estimate the migration cost. Decide if it's worth doing.
What's Next
Lesson 18.2 covers the useReducer + Context homegrown pattern - what you can build with just React's primitives, and where it stops being enough.