Quick answer: React Context lets you pass a value through the component tree without prop drilling, by creating a provider once near the top and reading it anywhere below with useContext. It is built for low-frequency, app-wide values: theme, current user, locale, feature flags. Context is not a state manager — a value change re-renders every consumer — so high-traffic state belongs in a store or in the components that use it.

Quick answer card explaining React Context in plain language.
React Context roadmap listing the sections in reading order.

The problem context solves: prop drilling

Props are the right default: a component’s data is visible at the call site, and refactoring is safe. The approach strains when a value must reach a leaf component five levels down, and every component in between forwards it without ever using it. That chain is prop drilling, and it makes every intermediate component’s signature change when the leaf’s needs change.

// Before: theme travels through components that never use it
<App theme={theme}>
  <Layout theme={theme}>
    <Sidebar theme={theme}>
      <Toggle theme={theme} />
    </Sidebar>
  </Layout>
</App>

Context replaces the chain with a single subscription. The provider sits at the top, the leaf reads the value, and the components in between forget the value exists. The trade is equally real: every consumer re-renders when the value changes, no matter how far away it is, and the data flow is no longer visible at the call sites. Context is the right tool when the value is genuinely global and changes rarely; the rest of this article is about staying on the correct side of that line.

Create, provide, consume: the three steps

Every context follows the same three moves: create it with a default, provide a value near the top of the tree, and consume it with useContext wherever it is needed.

// 1. Create (usually in its own module, with a typed default)
const AuthContext = createContext(null);

// 2. Provide
function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const login = (credentials) => api.login(credentials).then(setUser);
  const value = useMemo(() => ({ user, login }), [user]);
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

// 3. Consume, at any depth
function Avatar() {
  const { user } = useContext(AuthContext);
  return user ? <img src={user.avatar} alt="" /> : null;
}

Wrapping the provider and the hook in a single useAuth hook is the convention that keeps call sites clean and makes the dependency explicit: components import useAuth, not the raw context, so you can change the storage behind it without touching consumers. Two structural habits pay off immediately. Memoise the value object with useMemo, as described in the memoisation guide, and co-locate the context with its hook so nobody can import one without the other.

Concept map of React Context: provider, consumers and the subscription boundary.

A complete example: theme switching with persistence

Theme switching is the hello-world of context, and doing it properly shows the parts tutorials skip: reading the initial value without flashing the wrong theme, persisting the choice, and keeping the provider’s value stable.

const ThemeContext = createContext(null);

export function useTheme() {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error('useTheme must be used inside ThemeProvider');
  return ctx;
}

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState(() => {
    const saved = localStorage.getItem('theme');
    return saved ?? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
  });

  useEffect(() => {
    localStorage.setItem('theme', theme);
    document.documentElement.dataset.theme = theme;
  }, [theme]);

  const value = useMemo(() => ({ theme, setTheme }), [theme]);
  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

The lazy useState initialiser reads localStorage exactly once, during the first render, which avoids the common bug where the page paints light and then flips dark. The effect is the right home for the side effects — persistence and the DOM attribute — because they belong to the external world, not the render. And the thrown error in useAuth turns the worst failure mode, reading a context outside its provider, into an immediate, descriptive crash instead of a silent undefined that surfaces three screens later.

Splitting context to avoid pointless re-renders

The performance model of context is blunt: change the value and every consumer re-renders, even the ones that read a field that did not change. For a theme toggled a few times a day that is invisible. For a search query updating per keystroke and consumed by a sidebar, a header and a results list, it is the difference between a smooth app and a janky one.

// One fat context: every keystroke rerenders every consumer
<AppContext.Provider value={{ query, setQuery, user, locale }} />

// Split: setters and slow data are stable, only the hot half changes
<QueryContext.Provider value={query}>        {/* changes per keystroke */}
<ActionsContext.Provider value={actions}>    {/* memoised, never changes */}
<UserContext.Provider value={user}>          {/* changes on login */}

Split by update frequency, not by topic. Hot, volatile values get their own small context; stable actions and rarely-changing data share another. Beyond a handful of contexts, though, you are re-implementing a state library by hand, which is the signal to reach for one instead. Component-level state remains the baseline for everything local — the trade-offs of colocation are covered in the useState guide — and context only earns its place when the same value truly serves distant parts of the tree.

Multiple providers, defaults and gotchas

  • The default value only applies without a provider. Passing createContext(null) and forgetting the provider yields null everywhere, so the useTheme-style wrapper that throws is your safety net.
  • Contexts nest, and the nearest provider wins. That is a feature: tests wrap a subtree in fake providers, and multi-tenant UIs nest one provider inside another. It is also how a stray duplicate provider silently shadows the real one.
  • The value prop is compared by reference. An inline object literal rebuilds the context value every render, re-rendering all consumers constantly. Wrap it in useMemo.
  • Provider position matters more than it looks. Every consumer below re-renders when it changes, so moving a provider down to the smallest subtree that needs it is a one-line fix for a whole class of performance problems.

React 19 adds a small but welcome refinement: the provider component itself no longer forces a re-render when its own props change, only the value it publishes matters. The discipline is unchanged, though. Treat the context value as a public API: small, memoised, documented, and changed deliberately.

Context vs useReducer vs a state library

Once shared state carries real logic, a plain value context reaches its limit, and the usual next step is context plus useReducer: one dispatch function that never changes identity, one state object that does. That combination handles moderate complexity well and stays inside React.

NeedToolWhy
Theme, locale, user, feature flagsContextGlobal, low frequency, read widely
Medium app state with transitionsContext + useReducerCentral logic, still dependency-free
High-frequency updates, devtools, cachingZustand, Jotai, TanStack QuerySelective subscriptions, tooling
Server data: loading, caching, refetchingQuery library, not contextFetching is not component state

The honest summary for 2026 is that context won the API argument and lost the performance one. Its ergonomics — createContext, useContext, a provider in the tree — appear in every state library’s design, and the React team’s guidance remains: start with props and local state, add context for genuinely global low-frequency values, and move to a store only when measurement shows context re-renders costing you. The React Compiler reduces, but does not remove, that re-render cost, because a context subscription is a correctness boundary, not an optimisation opportunity. To learn React properly with a mentor, Ampersand Academy teaches web development one-to-one.

Coverage grid showing every section covered in this React Context article.

Organising context in large applications

Naming deserves the same care as structure. A context called AppContext or GlobalState accretes fields until nobody can tell what it guarantees, and every consumer re-renders on every change to any of them. Naming by meaning instead — ThemeContext, SessionContext, FlagContext — forces the split at the moment the value is created, keeps provider position reviewable, and makes the re-render blast radius obvious from the import alone. When a new field is proposed, the name then asks the useful question: does this belong with the values already here, or is it the start of a new context with its own update frequency and its own subtree?

Consumers then depend on the interface of a single typed hook rather than the raw context object, which keeps the provider free to swap its internals without touching a single call site. The same folder convention gives tests a natural seam: wrap a subtree in a minimal provider, assert on behaviour, and never mock the context directly. None of this requires a library — a folder, a naming rule and a hook are enough to keep the context layer legible for years.

At application scale the failure mode of context is not performance but structure: providers multiplied across feature folders, values shaped differently in each, and components importing the raw context object directly so a rename becomes a cross-cutting change. The convention that prevents this is one folder per context, exporting a provider component and a typed hook, with the createContext call unexported. Consumers then depend on the hook’s interface rather than the context object, and the provider’s internals — reducer, storage, side effects — can change without touching a single consumer file.

Composition is the second structural tool. A provider that does its own fetching, persistence and error handling in one component becomes untestable and hard to read; splitting it into a state-owning core plus thin providers for each concern keeps each piece small and lets tests wrap subtrees in just the slice they need. Nesting contexts also solves the variant problem: a modal system, a design system or a multi-tenant renderer can wrap a subtree in a provider that shadows the outer one, and the nearest provider wins by design. Used deliberately, that shadowing is a feature; discovered accidentally, it is a two-hour debugging session, so provider position deserves the same review attention as prop types.

Context is not for server data

Putting fetched data in context is the most common architectural mistake around the API, because the shape of the solution looks right: the data is app-wide, it changes, and consumers need it everywhere. What context lacks is every property that makes server state different from client state: no caching across mounts, no deduplication of concurrent requests, no background refetching, no retry on failure, and no way for one consumer to request more data without refetching the world. Each of those has to be rebuilt by hand inside the provider, badly, before the provider grows a query language of its own.

The cleaner split keeps server data in a query library, where cache keys, staleness and request deduplication are its entire job, and lets context hold only the truly client-side values — the current theme, the user’s preferences, feature flags. When a component needs both, it reads the query with its own hook and the global value with the context hook, and neither abstraction pretends to be the other. If the project already stores server data in context, the migration path is mechanical: move the fetch into a query hook, keep the provider for preferences only, and let the query library’s cache become the single source of truth for what the server said.

Testing components that consume context

The rule that makes context pleasant to test is the same one that makes it pleasant to build: components depend on the hook, not the raw context, and the hook has a provider that throws when missing. A test can then wrap the component in a minimal provider built for the test — real state, no side effects, no persistence — and assert on behaviour rather than mocks. Because the provider is the only contract, the storage behind it can be an in-memory map, the network can be a resolved promise, and the test never learns how production obtains the value.

For tests that need variation, the nested-provider trick from the composition section does double duty: a theme test renders dark and light by wrapping the same subtree twice, and an auth test swaps a user object between renders without touching the component under test. When a test still needs to reach the value directly, exposing a test-only read of the provider’s state is usually simpler than refactoring for injection. The habit worth keeping is that no test may import the context object itself — the moment that happens, the provider’s internals have leaked into the suite, and every future refactor of the provider becomes a test-suite rewrite.

Frequently asked questions

When should I use React Context?

For values that are genuinely app-wide and change rarely: theme, authenticated user, locale, feature flags. For anything local or high-frequency, keep state in the components that use it or use a store.

Does Context cause re-renders?

Yes. When the provider’s value changes, every consumer re-renders regardless of depth. Memoise the value and split hot values from stable ones to keep the blast radius small.

What does the default value in createContext mean?

It is returned by useContext when no provider is found above the component. Passing null and throwing inside a wrapper hook turns that silent mistake into a clear error.

Should I use Redux or Zustand instead of Context?

Not by default. Add a state library when you need selective subscriptions between consumers, devtools, middleware, or async workflows that make context plus useReducer awkward to maintain.

Can Context replace fetching libraries?

No. Context stores values; it does not model loading, caching, retries or refetching. Server state belongs in a query library or your data layer, and the result can still be exposed through context if it is truly global.

Last updated on · Written by