Skip to content

Context API

Context API helps you share data across a component subtree without manually passing props through every level.

It is best for app-wide or subtree-wide values that many components need, such as auth state, theme, locale, feature flags, and dependency objects.

When deeply nested components need the same data, props have to pass through many middle components that may not use that data.

graph LR APP["App"] --> A["A"] A --> B["B"] B --> C["C"] C --> D["D needs data"]
  • Boilerplate code.
  • Hard maintenance.
  • Tight coupling between layers.
  • Components in the middle must accept and forward props they do not use.

Context introduces a provider/consumer model.

graph TD P["Provider stores data"] --> T["Data available to subtree"] T --> C["Consumer reads data directly"]
  • Context: the shared channel created by createContext()
  • Provider: the component that supplies a value to descendants
  • Consumer: a component that reads a context value
  • useContext(): the modern way to read context in function components
  • Value: the data passed to the provider
  • Subtree: all descendant components under a provider
  • Re-render: React re-executes components that depend on changed context

Use context for values that are:

  • Needed by many components in the same subtree
  • Fairly stable or change at a reasonable frequency
  • Better as shared configuration than as local UI state

Good examples:

  • Authentication user/session info
  • Theme mode
  • Locale or language settings
  • UI preferences such as sidebar collapse state
  • Dependency injection objects such as API clients
  • Data passed only one or two levels
  • Very high-frequency changing data such as mouse position, animation frames, or typing state
  • Large app state that needs advanced devtools, persistence, or complex updates
  • Data that belongs only to one component or one small component group
import { createContext } from "react";
const UserContext = createContext(null);

Always give a sensible default when possible. null is common because it makes missing providers easier to detect.

<UserContext.Provider value={data}>
<App />
</UserContext.Provider>

The provider makes data available to every descendant component that reads this context.

<UserContext.Consumer>{(value) => <h1>{value}</h1>}</UserContext.Consumer>

This is the legacy render-prop API. It still works, but in modern function components, useContext() is usually better.

import { useContext } from "react";
function Profile() {
const user = useContext(UserContext);
return <h1>{user?.name}</h1>;
}

If the provider is missing, useContext() returns the default value from createContext().

  1. Create context.

    const ThemeContext = createContext("light");
  2. Provide value.

    function App() {
    return (
    <ThemeContext.Provider value="dark">
    <Child />
    </ThemeContext.Provider>
    );
    }
  3. Consume value.

    function Child() {
    const theme = useContext(ThemeContext);
    return <h1>{theme}</h1>;
    }

This is the simplest flow:

  • Create the context
  • Wrap the subtree in a provider
  • Read the value with useContext()
class Child extends React.Component {
static contextType = ThemeContext;
render() {
return <h1>{this.context}</h1>;
}
}

Class components are older style, but this API is still relevant when maintaining legacy code.

import { createContext, useMemo, useState } from "react";
const ThemeContext = createContext(null);
function App() {
const [theme, setTheme] = useState("light");
const value = useMemo(() => ({ theme, setTheme }), [theme]);
return (
<ThemeContext.Provider value={value}>
<Child />
</ThemeContext.Provider>
);
}

This is the standard way to both get and set data through context:

  • theme is the shared value
  • setTheme is the updater function
  • useMemo() keeps the provider value reference stable when the data has not changed
<UserContext.Provider value={user}>
<ThemeContext.Provider value={theme}>
<App />
</ThemeContext.Provider>
</UserContext.Provider>

Use multiple contexts when the concerns are unrelated. This keeps updates smaller and easier to reason about.

Examples:

  • AuthContext
  • ThemeContext
  • SettingsContext
<AppContext.Provider value={{ user, theme, locale, notifications, cart, permissions }}>

This creates a giant catch-all context. Every change can re-render many consumers, and the code becomes harder to maintain.

Pattern:

graph TD P["Provider passes state + updater"] --> C["Child reads context"] C --> U["Child calls updater"] U --> P
function App() {
const [count, setCount] = useState(0);
const value = useMemo(() => ({ count, setCount }), [count]);
return (
<CountContext.Provider value={value}>
<Child />
</CountContext.Provider>
);
}
function Child() {
const { count, setCount } = useContext(CountContext);
return (
<button onClick={() => setCount(count + 1)}>{count}</button>
);
}

The child can both read and update the shared value, but the provider still owns the state.

const CountContext = createContext(null);
function CountProvider({ children }) {
const [count, setCount] = useState(0);
const value = useMemo(() => ({ count, setCount }), [count]);
return <CountContext.Provider value={value}>{children}</CountContext.Provider>;
}
function useCount() {
const context = useContext(CountContext);
if (!context) {
throw new Error("useCount must be used within CountProvider");
}
return context;
}

This custom hook gives you:

  • Clearer usage
  • Better error messages when the provider is missing
  • A single place to evolve the context contract

Each context object contains:

{
currentValue,
Provider,
Consumer
}
graph TD A["Provider updates value"] --> B["React marks subtree"] B --> C["Consumers re-render"]

Important behavior:

graph TD A["Provider value changes"] --> B["Consumers using this context"] B --> C["They can re-render"]

Problem example:

value={{ user, theme }}

If only theme changes, user consumers may still re-render.

That happens because React compares provider values by reference, not by deep inspection of object contents.

Split Context

Use smaller contexts like UserContext and ThemeContext instead of one big object.

Memoize Value

Use useMemo for stable provider value object references.

Avoid Large Objects

Smaller focused values reduce unnecessary consumer updates.

const value = useMemo(() => ({ user, setUser }), [user]);

Additional optimization rules:

  • Keep provider values as small as possible
  • Pass functions separately only when needed
  • Prefer splitting a context over memoizing a huge object
  • Use local component state first if the data does not need to be shared
function Provider({ children }) {
const [state, setState] = useState({ user, theme, locale });
return <AppContext.Provider value={{ state, setState }}>{children}</AppContext.Provider>;
}

This is hard to optimize because almost any update changes the object reference and can re-render many consumers.

function ThemeButton() {
const { theme, setTheme } = useContext(ThemeContext);
return (
<button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>Current: {theme}</button>
);
}

The provider owns the data. Consumers read the value and call the updater. That is the cleanest Context pattern for beginners.

FeaturePropsContext
ScopeLocalGlobal for subtree
ControlExplicitImplicit
ReusabilityHighModerate
DebuggingEasyHarder

Use props when the data only travels one or two levels. Use context when many descendants need the same value.

FeatureContextRedux
ComplexityLowHigh
PerformanceMediumHigh
DevToolsLimitedPowerful
Use caseSmall-medium appsLarge apps

Context is not a full state management framework. If your state needs logging, time travel, complex async flows, or large shared updates, Redux Toolkit or Zustand may be better.

import { createContext, useReducer } from "react";
const StoreContext = createContext(null);
function reducer(state, action) {
switch (action.type) {
case "increment":
return { count: state.count + 1 };
default:
return state;
}
}

This pattern is useful when updates are more complex than simple setters. It keeps the state transitions predictable.

function StoreProvider({ children }) {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return <StoreContext.Provider value={{ state, dispatch }}>{children}</StoreContext.Provider>;
}

In production, this wrapper keeps provider setup out of your app tree and makes the context reusable.

function useStore() {
const context = useContext(StoreContext);
if (!context) {
throw new Error("useStore must be used within StoreProvider");
}
return context;
}

This is a strong production pattern because it prevents silent failures when a provider is missing.

  • Overusing context for every piece of state
  • Passing very large context objects
  • Forgetting to memoize object values passed to providers
  • Reading context in places that do not need to subscribe to it
  • Deep nesting of providers without structure
  • Storing rapidly changing state in one top-level context
  • Building contexts without custom hooks and provider guards
  • Split by domain: auth, theme, settings, etc.
  • Keep provider values stable with useMemo
  • Put update logic in one provider or reducer
  • Create a custom hook for each context
  • Prefer props for simple parent-to-child data flow
graph TD APP["App"] --> AUTH["AuthProvider"] APP --> THEME["ThemeProvider"] APP --> SETTINGS["SettingsProvider"]

This structure is common in production apps because each provider owns one concern.

Example layout:

function App() {
return (
<AuthProvider>
<ThemeProvider>
<SettingsProvider>
<Router />
</SettingsProvider>
</ThemeProvider>
</AuthProvider>
);
}
Context = broadcast system

When the provider value changes, React broadcasts that change to the consuming subtree.

graph LR P["Provider pushes value"] --> C["Consumers subscribe to value"]

Fiber integration:

Each fiber tracks context dependencies

React uses the provider value reference to decide which consuming components need to re-render.

graph TD A["Provider value changes"] --> B["React compares old vs new (Object.is)"] B --> C["Marks dependent consumers"] C --> D["Schedules re-render"]

That is why a new object literal like value={{ user }} can cause extra work even if the contents look the same.

  • Too many updates.
  • Complex state logic.
  • Hard debugging.

Then consider:

  • Redux
  • Zustand
  • Recoil

In practice:

  • Use Context for shared values and light coordination
  • Use Zustand or Redux when you need centralized app state with better tooling and update control

Before creating a context, ask:

  • Does more than one distant component need this data?
  • Does the data need to be shared across a subtree?
  • Is this better as shared config than local component state?
  • Will the value change often enough to cause render cost problems?

If the answer is no, use props or local state instead.