Skip to main content

Context Deep Dive

What you'll learn

  • What context actually is: a scoped value channel positioned in the tree
  • How the default value works, and the bug it hides
  • The propagation mechanism, and why context cuts straight through React.memo
  • The identity trap that makes consumers re-render constantly, and the one-line fix
  • When context is the right tool, and when plain composition replaces it entirely

Passing a theme through five layers of components that don't care about it, prop drilling, is the pain that created context. Context is React's answer: a channel that delivers a value to any component below, skipping every layer in between. But context is widely misunderstood. It is not a state manager, it does not respect memo boundaries, and one forgotten provider produces crashes far from the cause. Let's open it up.

Creating, providing, reading

Three pieces make a context work: a channel object, a provider that puts a value into it, and consumers that read it out:

import { createContext, useContext, useState } from 'react';

const ThemeContext = createContext('light');

function Toolbar() {
  return (
    <div className="toolbar">
      <ThemedButton />
    </div>
  );
}

function ThemedButton() {
  const theme = useContext(ThemeContext);
  return <button className={theme}>I am themed: {theme}</button>;
}

export default function App() {
  const [theme, setTheme] = useState('dark');

  return (
    <ThemeContext value={theme}>
      <Toolbar />
      <button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
        Toggle theme
      </button>
    </ThemeContext>
  );
}

What happens:

  1. createContext('light') makes a channel object; 'light' is its default value.
  2. <ThemeContext value={theme}> opens the channel for everything rendered inside it. (In older code you will see <ThemeContext.Provider value={theme}>, same thing, older spelling.)
  3. useContext(ThemeContext) in ThemedButton walks up the tree to the nearest provider and reads its value. Toolbar, sitting in between, passes nothing at all.
  4. Toggle: App re-renders, the provider's value prop changes, and every consumer re-renders with the new theme.

Jargon: "provider". The element that puts a value into a context for everything rendered below it. "Consumer", any component that reads the value with useContext. One provider can feed any number of consumers, at any depth.

The nearest provider wins: and the default is a trap

Context is scoped by tree position, not global. The same component can read different values in different subtrees:

import { createContext, useContext } from 'react';

const ThemeContext = createContext('light');

function ThemedButton() {
  const theme = useContext(ThemeContext);
  return <button className={theme}>themed: {theme}</button>;
}

export default function App() {
  return (
    <div>
      <ThemeContext value="dark">
        <ThemedButton />
        <ThemeContext value="light">
          <ThemedButton />
        </ThemeContext>
      </ThemeContext>
      <ThemedButton />
    </div>
  );
}

What happens: the first button reads 'dark'. The second reads 'light', the nearest provider above it wins. The third has no provider above it at all, so it reads the default from createContext: 'light'.

That third button is where a classic bug lives. The default value is used only when no provider exists, not as a fallback, not as an "empty" state. So this crashes deep in the tree:

import { createContext, useContext } from 'react';

const UserContext = createContext(null);

function Avatar() {
  const user = useContext(UserContext);
  return <img src={user.avatarUrl} alt={user.name} />; // 💥 if no provider: user is null
}

export default function App() {
  // Oops — forgot to wrap anything in <UserContext value={...}>
  return <Avatar />;
}

The error ("cannot read properties of null") points at Avatar, but the mistake is in App. The standard fix is a guard hook that turns the confusing, far-away crash into a loud, local one:

import { createContext, useContext } from 'react';

const UserContext = createContext(null);

export function useUser() {
  const user = useContext(UserContext);
  if (user === null) {
    throw new Error('useUser must be used inside a <UserContext> provider');
  }
  return user;
}

function Avatar() {
  const user = useUser();
  return <img src={user.avatarUrl} alt={user.name} />;
}

export default function App() {
  const user = { name: 'Ada', avatarUrl: '/ada.png' };
  return (
    <UserContext value={user}>
      <Avatar />
    </UserContext>
  );
}

The mechanism: consumers subscribe during render

Pseudocode model, not real source:

// While rendering a consumer:
// useContext(Ctx):
// provider = nearest provider of Ctx above this component
// record: "this component depends on that provider's value"
// return provider ? provider.value : Ctx.defaultValue
//
// When a provider re-renders:
// if !Object.is(oldValue, newValue):
// mark every recorded consumer for re-render — however deep

Two details in that model do all the work. First, the dependency is recorded per consumer, during render, React knows exactly which components read which context. Second, when the value changes, the mark goes directly to those consumers. The components between provider and consumer are not consulted.

Context bypasses memo

That "not consulted" clause has teeth. A memoized component between the provider and the consumer does not block the update:

import { createContext, memo, useContext, useState } from 'react';

const ColorContext = createContext('blue');

const Middle = memo(function Middle({ children }) {
  console.log('Middle rendered');
  return <div className="middle">{children}</div>;
});

function Swatch() {
  const color = useContext(ColorContext);
  return <div style={{ background: color, width: 40, height: 40 }} />;
}

export default function App() {
  const [color, setColor] = useState('blue');

  return (
    <ColorContext value={color}>
      <Middle>
        <Swatch />
      </Middle>
      <button onClick={() => setColor('red')}>Make red</button>
    </ColorContext>
  );
}

What happens:

  1. Click "Make red": App re-renders and the provider's value prop changes to 'red'.
  2. Middle is memoized and its props (children) are unchanged → Middle does not re-render. The console stays silent.
  3. Swatch is a recorded consumer of ColorContext → it is marked directly → it re-renders with 'red'.
  4. The memo boundary between provider and consumer did nothing. Context propagation does not walk down through props; it jumps.

The identity trap

Since change detection is Object.is on the value prop, a fresh object literal is a silent performance landmine:

import { createContext, useContext, useState } from 'react';

const PrefsContext = createContext(null);

function Consumer() {
  const prefs = useContext(PrefsContext);
  console.log('Consumer rendered');
  return <p>Theme: {prefs.theme}</p>;
}

export default function App() {
  const [tick, setTick] = useState(0);

  return (
    <PrefsContext value={{ theme: 'dark' }}>
      <button onClick={() => setTick(tick + 1)}>Tick {tick}</button>
      <Consumer />
    </PrefsContext>
  );
}

What happens:

  1. Click "Tick": App re-renders.
  2. The value prop is a fresh object literal: { theme: 'dark' }, same contents, new identity.
  3. Object.is(oldValue, newValue) is false → React marks every consumer.
  4. "Consumer rendered" logs on every tick, even though the theme is always 'dark'.

The fix is the previous chapter's tool, stabilize the value:

import { createContext, useContext, useMemo, useState } from 'react';

const PrefsContext = createContext(null);

function Consumer() {
  const prefs = useContext(PrefsContext);
  console.log('Consumer rendered');
  return <p>Theme: {prefs.theme}</p>;
}

export default function App() {
  const [tick, setTick] = useState(0);
  const prefs = useMemo(() => ({ theme: 'dark' }), []);

  return (
    <PrefsContext value={prefs}>
      <button onClick={() => setTick(tick + 1)}>Tick {tick}</button>
      <Consumer />
    </PrefsContext>
  );
}

Now ticks leave the consumer alone. Rule of thumb: the value prop should be a state value, a memoized object, or a module constant, never a fresh literal.

Split contexts by change frequency

Because any change to the value re-renders every consumer, one mega-context holding user + theme + cart + mouse position will re-render the world on every mousemove. Split it:

<UserContext value={user}> {/* changes on login/logout */}
<ThemeContext value={theme}> {/* changes on toggle */}
<SelectionContext value={sel}> {/* changes as the user clicks */}

Each consumer subscribes only to what it reads. High-frequency values get their own narrow context, placed close to where they are consumed.

What context is NOT

Context is not a state manager. It stores nothing and updates nothing, the value still comes from a useState or useReducer in a component above; context only delivers it. And it has no selectors: a consumer re-renders on any change to the value, even a field it never reads. If you need frequent, fine-grained updates (a live cart, collaborative editing state), the standard move is an external store with selector subscriptions, context alone is the wrong substrate. Keep context for low-frequency, widely-needed values: theme, locale, the logged-in user, feature flags.

Often you don't need context at all

If your only problem is "pass a component through layers that don't care about it", composition solves it with zero machinery:

import { useState } from 'react';

function Layout({ children }) {
  return <main className="layout">{children}</main>;
}

function ColoredButton({ color }) {
  return <button style={{ background: color }}>I know my color</button>;
}

export default function App() {
  const [color, setColor] = useState('blue');

  return (
    <Layout>
      <ColoredButton color={color} />
    </Layout>
  );
}

What happens: App builds ColoredButton with color as a normal prop and hands it down through Layout as children. Layout never learns that color exists; no context is created. Reach for context when many components at different depths need the same value, not to spare one intermediate layer a prop.

Diagram

Rendered diagram (PNG hi-res):

rendered diagram

Rendered diagram (PNG hi-res):

rendered diagram

Common misconceptions

  • Misconception: context is a state manager. Reality: it stores nothing; state lives in a component above and context merely delivers the value.
  • Misconception: the default value is a fallback for when the provider has no value. Reality: it is used only when no provider exists above the consumer. A provider with value={null} gives consumers null, not the default.
  • Misconception: React.memo blocks context updates. Reality: consumers are marked directly; memoized components between provider and consumer are skipped, not consulted.
  • Misconception: changing context re-renders the whole app. Reality: only components that called useContext for that context re-render (plus the provider's own subtree, for ordinary render reasons).
  • Misconception: a consumer can subscribe to one field of the value. Reality: there are no selectors, any change to the value re-renders every consumer, so split contexts by change frequency.

Why it works this way

  • Scoping by tree position means the same component can read different values in different subtrees, something a global variable can never do.
  • Registering the dependency during render is what makes propagation surgical: React knows exactly which components read which context, so updates skip everything else.
  • Object.is on the value prop is cheap and predictable, but it makes you responsible for identity, which is why the useMemo habit exists.
  • Context was designed for low-frequency broadcast (theme, locale, auth). Fine-grained, high-frequency state needs selector subscriptions, which context deliberately does not provide.

Try it yourself

  1. Build the nested-providers tree, then move the third button inside the outer provider: predict which value each button reads before you run it.
  2. Render the guarded Avatar outside any provider, first with raw useContext (cryptic null crash), then with useUser (a clear, local error).
  3. In the identity-trap demo, click "Tick" five times and count "Consumer rendered" logs. Apply the useMemo fix and click five more. Compare.
  4. In the memo-bypass demo, add a second memoized sibling that does not use the context: watch it stay silent while Swatch updates.

Recap

  • Context = createContext(default) + a provider <Ctx value={v}> + useContext(Ctx); consumers read the nearest provider above them.
  • The default applies only when there is no provider at all, a classic crash source; guard with a custom hook.
  • Consumers register during render; a changed value (Object.is) marks them all, straight through memo boundaries.
  • A fresh object literal in value re-renders every consumer on every provider render. Memoize the value.
  • Context re-renders every consumer on any change (no selectors), so split contexts by change frequency.
  • Context is a delivery channel, not a state manager, and often passing children down avoids it entirely.

Next

useReducer and advanced state →