React
Context API
State Management
TypeScript
Next.js
Frontend

React Context API: Provider, useContext, and Its Limits

How React Context actually works: create a provider, read it with useContext, avoid the re-render trap, and know when Context beats Redux and when it loses.

12 min read
Chamikara Nayanajith

Passing a value down four levels of components so that one leaf can read it is miserable, and React Context is the built-in fix. It is also the most misunderstood API in React: people reach for it as a state management library, hit a wall of re-renders, and conclude Context is slow. Context is not slow. It just does something narrower than most tutorials imply.

What does React Context actually solve?

Context is dependency injection. It moves a value from a provider to any descendant without threading it through every component in between. That is the whole feature. It does not store state, it does not batch updates, and it has no concept of subscribing to part of a value.

The problem it removes looks like this. Here a theme that only Button cares about, dragged through two components that do not:

tsx
function App() {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');
  return <Page theme={theme} onThemeChange={setTheme} />;
}

// Page does not use theme. It only forwards it.
function Page({ theme, onThemeChange }: PageProps) {
  return <Toolbar theme={theme} onThemeChange={onThemeChange} />;
}

// Neither does Toolbar.
function Toolbar({ theme, onThemeChange }: ToolbarProps) {
  return <Button theme={theme} onClick={() => onThemeChange('dark')} />;
}

Two components carry props they never read. Add a third level and a second value and the signatures start to rot.

Creating a context with createContext

A context is created once, outside any component, and gives you a provider to wrap a subtree with.

Typing it without a fake default

Most examples pass a plausible-looking default to createContext, like an empty object or a no-op function. Do not. That default is only used when a component reads the context with no provider above it, which is always a bug, and a fake default turns that bug into silence. Type it as possibly undefined instead:

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

type Theme = 'light' | 'dark';

type ThemeContextValue = {
  theme: Theme;
  setTheme: (theme: Theme) => void;
};

// undefined is the honest default: there is no theme without a provider.
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);

If you want a refresher on the generics and union types here, I covered the underlying patterns in React and TypeScript.

The provider component

The provider is an ordinary component that owns the state and hands it down. Nothing about it is special except the value prop:

tsx
export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>('light');

  const value = useMemo(() => ({ theme, setTheme }), [theme]);

  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

That useMemo is not decoration. I will come back to why it matters more than it looks, and the general rules for it are in useMemo, useCallback and React.memo.

Reading context with useContext

useContext reads the nearest provider above the component. Calling it directly works, but it leaves every consumer handling the undefined case:

tsx
function Button() {
  const ctx = useContext(ThemeContext);
  // ctx is ThemeContextValue | undefined, so every caller has to narrow it.
  return <button className={ctx?.theme}>Click</button>;
}

A custom hook that fails loudly

Wrap the read in a hook that throws when the provider is missing. You write it once, and every consumer gets a non-optional type plus an error message that names the actual problem instead of Cannot read properties of undefined somewhere three components away:

tsx
export function useTheme(): ThemeContextValue {
  const context = useContext(ThemeContext);

  if (context === undefined) {
    throw new Error('useTheme must be used inside a ThemeProvider');
  }

  return context;
}

// Consumers get a clean, narrowed type.
function Button() {
  const { theme, setTheme } = useTheme();
  return <button className={theme} onClick={() => setTheme('dark')}>Click</button>;
}

This is the single highest-value pattern in this post. Export the hook and the provider; keep the context object itself private to the module.

React 19 added use(), which reads a context too and, unlike useContext, may be called conditionally or inside a loop. It is genuinely useful for reading a context only on one branch. For the common case, useContext inside a custom hook is still the clearer default.

Nesting providers overrides, it does not merge

useContext walks up the tree and stops at the first matching provider. Render the same provider twice and the inner one wins completely for everything below it. There is no merging of values, and no warning that it happened:

tsx
<ThemeProvider>          {/* theme: light */}
  <Sidebar />            {/* reads light */}
  <ThemeProvider>        {/* theme: dark  */}
    <Preview />          {/* reads dark, not a merge of both */}
  </ThemeProvider>
</ThemeProvider>

This is occasionally exactly what you want: a preview pane rendering in the opposite theme, a form section with its own validation config. More often it happens by accident, when a provider that belongs in the root layout also gets mounted inside a feature component, and half the app silently reads a second, unrelated instance of the state. If a context value looks stale and no update seems to reach it, check for a second provider before checking anything else.

Testing a component that reads context

Because the hook throws without a provider, tests fail loudly and immediately, which is the behaviour you want. Wrap the component under test rather than mocking the module:

tsx
import { render, screen } from '@testing-library/react';

function renderWithTheme(ui: React.ReactNode) {
  return render(<ThemeProvider>{ui}</ThemeProvider>);
}

test('button picks up the theme', () => {
  renderWithTheme(<Button />);
  expect(screen.getByRole('button')).toHaveClass('light');
});

A shared renderWithProviders helper that wraps every provider the app uses is worth writing on day one. The alternative, mocking useContext per test, couples tests to the implementation and breaks the moment you split a context in two.

The re-render problem nobody warns you about

Here is the failure that gives Context its reputation. This provider looks fine and is quietly broken:

tsx
function AppProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [theme, setTheme] = useState<Theme>('light');

  // A brand new object on every single render.
  return (
    <AppContext.Provider value={{ user, setUser, theme, setTheme }}>
      {children}
    </AppContext.Provider>
  );
}

The object literal is recreated on every render of AppProvider. React compares context values by identity, so every consumer re-renders every time, even a component that only reads theme, when only user changed. In an app with a few dozen consumers this is invisible. In one with a few hundred, it is the reason typing in a form feels sticky.

Why does React.memo not help here?

The instinct is to wrap consumers in React.memo. It does nothing here. memo skips re-renders caused by unchanged props; a context read is not a prop. A memoised component that calls useTheme() still re-renders whenever the context value changes identity. I have watched people add memo to a dozen components and measure no improvement at all before finding this.

Fix one: memoise the value

Give the provider a stable object so identity only changes when the data does:

tsx
const value = useMemo(
  () => ({ user, setUser, theme, setTheme }),
  [user, theme] // setUser and setTheme are stable across renders
);

State setters from useState are guaranteed stable, so they do not belong in the dependency array. This fixes re-renders caused by the provider's own parent re-rendering. It does not fix the second problem: a theme consumer still re-renders when user changes, because they share one value.

Fix two: split the contexts

Unrelated values belong in unrelated contexts. If theme and user change on different schedules, give them separate providers and consumers stop interfering with each other:

tsx
// Two contexts, two providers, no cross-talk.
export function AppProviders({ children }: { children: React.ReactNode }) {
  return (
    <ThemeProvider>
      <UserProvider>{children}</UserProvider>
    </ThemeProvider>
  );
}

There is a sharper version of this for state that changes often: split state from dispatch. A useReducer dispatch function is stable for the lifetime of the component, so components that only dispatch never re-render when the state changes:

tsx
const TodosStateContext = createContext<Todo[] | undefined>(undefined);
const TodosDispatchContext = createContext<React.Dispatch<Action> | undefined>(
  undefined
);

export function TodosProvider({ children }: { children: React.ReactNode }) {
  const [todos, dispatch] = useReducer(todosReducer, []);

  return (
    <TodosStateContext.Provider value={todos}>
      {/* dispatch never changes identity, so this provider never invalidates */}
      <TodosDispatchContext.Provider value={dispatch}>
        {children}
      </TodosDispatchContext.Provider>
    </TodosStateContext.Provider>
  );
}

An "Add todo" button reads only the dispatch context and stays put while the list re-renders around it. This pattern carries a surprising amount of weight before you need anything external.

Using Context in the Next.js App Router

This trips up more people than the re-render problem, because the error message points at the wrong thing. Server Components have no state, no effects and no hooks, so they cannot call useContext and cannot render a provider. Put createContext in a file without 'use client' and the build fails with createContext is not a function, which sounds like a broken import rather than a boundary violation.

The fix is to isolate providers in their own client file:

tsx
'use client';

import { ThemeProvider } from '@/lib/theme-context';
import { UserProvider } from '@/lib/user-context';

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ThemeProvider>
      <UserProvider>{children}</UserProvider>
    </ThemeProvider>
  );
}

Then mount it in the root layout, which stays a Server Component:

tsx
// app/layout.tsx (no 'use client' here)
import { Providers } from './providers';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

The detail worth internalising: children passed through a client provider stays a Server Component. The provider renders on the client, but it does not drag the whole tree across the boundary. It receives already-rendered children as an opaque prop. The common mistake is skipping the separate file and adding 'use client' to layout.tsx itself, which does convert everything below it into client components and quietly undoes a large part of why you are using the App Router.

One consequence people miss: a context value cannot cross from server to client. If a Server Component fetches the current user, you pass it into the provider as a prop and the provider puts it in context. There is no way to read server-side context from a client component, and no way to put a function in a context value that a Server Component provides.

React Context vs Redux

This comparison gets framed as a choice between two state management tools, which is the root of the confusion. Redux is a state container: it holds state, defines how it changes, and lets components subscribe to slices of it. Context is a delivery mechanism with none of those things. The real pairing is Context plus useReducer against Redux.

 Context + useReducerRedux Toolkit
Bundle costZero, built in~13 kB gzipped
Selective subscriptionsNo, split contexts manuallyYes, via selectors
DevToolsReact DevTools onlyTime-travel debugging
MiddlewareNoneThunks, listeners, RTK Query
BoilerplateLow for one store, grows per contextHigher upfront, flat after

The line I use: if the state is read by many components and written by few, and updates are infrequent, Context is enough. Theme, locale, authenticated user, feature flags. Once you are splitting contexts a third time to dodge re-renders, you have rebuilt a worse version of a store, and you should pick a real one. Zustand is the cheapest exit. I compared the options in React state management.

When is Context the wrong tool?

Two cases, and the second is the one that actually bites.

High-frequency updates. Cursor position, form field values, anything tied to scroll or animation. Every change re-renders every consumer, and you cannot subscribe to part of a value. Keep it local, or use a store with selectors.

Server data. This is the big one. Putting fetched data in Context means reimplementing caching, deduplication, revalidation and error retries by hand, badly. Server state has different requirements from client state and wants a different tool. See fetching data in React for what those requirements actually are.

What I reach for

Context for values that are genuinely global and rarely change, always behind a custom hook that throws, always with a memoised value. Context plus useReducer with split state and dispatch when there is real logic but no need for a dependency. A store like Zustand the moment I want selectors. And a query library, never Context, for anything that came from a server.

Context is not a small Redux. It is a way to skip prop drilling, and it is very good at that.

Further reading

Frequently asked questions

Is React Context slow?

No. Context has no performance problem of its own. It compares values by identity and re-renders every consumer when that identity changes, which is exactly what it is specified to do. Almost every complaint about Context being slow turns out to be a provider passing a fresh object literal on each render, so the identity changes every time even when nothing inside it did. Fix the value and the performance goes with it. The place Context genuinely does not fit is high-frequency state, because there is no way to subscribe to one field of the value: a consumer reading only the theme still re-renders when an unrelated field changes. That is a design limit rather than a slow implementation, and the answer is to split contexts or use a store. Profile before optimising here, because the fix for a provider that re-renders too often and a consumer that renders too slowly are different fixes.

Why does every consumer re-render when only one value changed?

Because the provider is passing an object built inline, so a new reference exists on every render even when the contents are identical. React compares by identity, sees a different value, and notifies every consumer, including ones reading a field that did not change. Wrap the value in useMemo with the right dependencies, and wrap any functions you put in it with useCallback, or the memo will be defeated by a fresh function identity. If a single context genuinely holds unrelated state that updates at different rates, splitting it into two contexts is the better fix than memoising harder. A common shape is one context for the value and a second for the setter, since the setter identity is stable and its consumers then never re-render on data changes at all. React DevTools can highlight re-renders visually, which turns this from a guess into a two minute check.

Should I pass a default value to createContext?

No, and this is the most common mistake in Context tutorials. The default only applies when a component reads the context with no provider above it, which is always a bug in your tree. A plausible-looking default hides that bug rather than surfacing it, and what you get is a component rendering with empty data instead of an error telling you the provider is missing. Type the context as possibly undefined, pass undefined as the default, and read it through a custom hook that throws with a message naming the provider. That hook is worth writing for a second reason: it gives you one place to narrow the type, so every consumer gets a non-optional value and none of them need their own undefined check. The one honest exception is a context whose absence is genuinely valid, such as an optional theme override that falls back to a documented default.

Can Context replace Redux?

For dependency injection, yes, and that is what it is for: theme, locale, the current user, anything read widely and written rarely. As a store it loses on two specific counts. It cannot subscribe to part of a value, so every consumer re-renders when any field changes, and it has no way to read or update state from outside the component tree, which rules out updating from an event handler in non-React code or a websocket callback held elsewhere. If your state changes often, has many independent consumers, or needs to be touched from outside React, a real store is the right tool. The useful mental model is that Context moves values down the tree, while a store owns them. Combining them is normal rather than a compromise: a store owns the data, and a Context passes the store instance down when an app needs more than one of them.

Related Articles