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.
| Approach | Re-render scope | Best for |
|---|---|---|
| Single combined context | All consumers on any change | Tiny, rarely-changing values |
| State + dispatch split | Setters never re-render readers | Reducer-style state |
| One context per concern | Isolated per concern | Independent 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
valuewithuseMemo(or rely on stabledispatch) 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.