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.

9 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 React Context actually solves

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.

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 React.memo does not help

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 Context is 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

Related Articles