Skip to content
React rc patterns 4 min read

The Provider Pattern

The provider pattern uses React Context to push shared dependencies and state down the tree without threading props through every intermediate component. Instead of passing a theme, the current user, or an API client manually at each level, you wrap a subtree in a provider once and let any descendant read the value through a hook. It is React’s idiomatic answer to dependency injection, and it underpins almost every serious library — routers, data-fetching clients, theming systems, and form frameworks all ship a provider.

Why providers exist

Props are explicit and local, which is exactly what you want for most data. But some values are genuinely global to a region of the app: the logged-in user, the color theme, a configured HTTP client. Passing those through ten layers of components (“prop drilling”) couples every middle component to data it does not use. A provider lets you declare the value at the top and consume it precisely where it is needed.

Reach for a provider when a value is read by many components at different depths and changes rarely relative to renders. For frequently-changing, narrowly-scoped state, plain props or local state are still better.

A minimal provider

The core building blocks are createContext, a provider component that supplies the value, and a custom hook that reads it. Bundling all three in one module gives consumers a clean, typed API and hides the raw context.

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

const ThemeContext = createContext(null);

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState("dark");
  const toggle = () => setTheme((t) => (t === "dark" ? "light" : "dark"));

  return (
    <ThemeContext.Provider value={{ theme, toggle }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  const ctx = useContext(ThemeContext);
  if (ctx === null) {
    throw new Error("useTheme must be used inside a <ThemeProvider>");
  }
  return ctx;
}

Any component can now consume it without prop drilling:

import { useTheme } from "./theme-context";

function ThemeToggle() {
  const { theme, toggle } = useTheme();
  return <button onClick={toggle}>Switch to {theme === "dark" ? "light" : "dark"}</button>;
}

The thrown error is the unsung hero here. If someone renders ThemeToggle outside the provider, they get a clear message instead of a confusing null reference.

Composing multiple providers

Real apps need several providers at once — auth, theme, a query client, a router. The naive approach nests them directly in your root, which works but quickly becomes a deep, hard-to-read pyramid often called “provider hell”.

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <AuthProvider>
        <ThemeProvider>
          <RouterProvider router={router} />
        </ThemeProvider>
      </AuthProvider>
    </QueryClientProvider>
  );
}

The fix is to flatten the nesting with a small composition helper. Order still matters — inner providers can depend on outer ones — but the JSX stays linear.

function composeProviders(...providers) {
  return ({ children }) =>
    providers.reduceRight(
      (tree, Provider) => <Provider>{tree}</Provider>,
      children
    );
}

const AppProviders = composeProviders(AuthProvider, ThemeProvider);

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <AppProviders>
        <RouterProvider router={router} />
      </AppProviders>
    </QueryClientProvider>
  );
}

Providers that take props (like QueryClientProvider and RouterProvider) stay outside the helper, while your own prop-less providers compose cleanly inside it.

Performance: split your contexts

A common pitfall is putting everything into one context object. Every consumer re-renders whenever any field changes, even fields it does not read. Splitting state from its setters — or splitting unrelated concerns into separate contexts — limits re-renders to the components that actually care.

ApproachRe-render scopeBest for
Single combined contextAll consumers on any changeTiny, rarely-changing values
State + dispatch splitSetters never re-render readersReducer-style state
One context per concernIsolated per concernIndependent app-wide values
const StateContext = createContext(null);
const DispatchContext = createContext(null);

export function CounterProvider({ children }) {
  const [count, dispatch] = useReducer((n, action) => {
    return action === "inc" ? n + 1 : n - 1;
  }, 0);

  return (
    <DispatchContext.Provider value={dispatch}>
      <StateContext.Provider value={count}>{children}</StateContext.Provider>
    </DispatchContext.Provider>
  );
}

Because dispatch from useReducer is referentially stable, components that only dispatch never re-render when count changes.

Output:

Render: CounterDisplay (count changed)
(dispatch-only buttons did NOT re-render)

Best practices

  • Co-locate the context, provider, and consumer hook in a single module and export only the hook and provider — never the raw context.
  • Throw a descriptive error in the hook when the value is missing so misuse fails loudly at the right place.
  • Memoize the provider value with useMemo (or rely on stable dispatch) to avoid re-rendering all consumers on every parent render.
  • Split large contexts by concern, and separate state from setters, to keep re-renders narrow.
  • Keep prop-taking providers (query client, router) explicit at the root and compose only your own prop-less providers with a helper.
  • Default to props for local data; promote to a provider only when a value is truly cross-cutting and read at many depths.
Last updated June 14, 2026
Was this helpful?