Composing React Providers Without the Nesting Nightmare
If you've worked on a React app with more than two context providers, you've seen this:
<ThemeProvider>
<AuthProvider>
<ToastProvider>
<ModalProvider>
<AnalyticsProvider>
<FeatureFlagProvider>{children}</FeatureFlagProvider>
</AnalyticsProvider>
</ModalProvider>
</ToastProvider>
</AuthProvider>
</ThemeProvider>Every new provider adds another level of indentation. The ordering matters but isn't obvious. Adding, removing, or reordering providers means carefully editing nested JSX. It's a maintenance tax that scales linearly with your provider count.
The Pattern
Here's a composeProviders utility that reduces the nesting to a flat list:
import { ComponentType } from 'react';
type WithChildren = { children: React.ReactNode };
const composeProviders = (providers: ComponentType<WithChildren>[]) =>
providers.reduce((Acc, Curr) => {
const Composed = ({ children }: WithChildren) => (
<Acc>
<Curr>{children}</Curr>
</Acc>
);
Composed.displayName =
`${Acc.displayName ?? Acc.name}(${Curr.displayName ?? Curr.name})`;
return Composed;
});Now the provider tree reads like a configuration:
const Providers = composeProviders([
ThemeProvider,
ToastProvider,
ModalProvider,
]);
export default function AppContext({ children }: WithChildren) {
return (
<Providers>
<Nav />
{children}
</Providers>
);
}Why This Works
It's just reduce
The utility walks the array left to right, wrapping each provider around the next. The result is functionally identical to hand-written nesting — it produces the same component tree at runtime. No runtime overhead, no extra renders, no magic.
Display names are automatic
The displayName assignment means React DevTools shows the full provider chain:
ThemeProvider(ToastProvider(ModalProvider(...)))
Without this, composed components show as Anonymous in the component tree, making debugging impossible. The ?? Acc.name fallback handles providers that don't set displayName explicitly.
Order is explicit and scannable
Provider order matters — inner providers can consume outer providers' contexts. In nested JSX, this ordering is implicit in the indentation hierarchy. In the array, it's a flat list you can read top to bottom. Reordering is a cut-and-paste of a single line.
Adding a provider is a one-line change
const Providers = composeProviders([
ThemeProvider,
ToastProvider,
ModalProvider,
+ NotificationProvider,
]);No indentation changes, no bracket matching, no risk of accidentally nesting in the wrong position.
When NOT to Use This
This pattern works well for app-level providers that wrap your entire tree — theme, auth, toast, modals. It's not a good fit for:
- Providers with custom props: If a provider needs configuration (
<QueryProvider client={queryClient}>), you can't pass it as a bare component reference. You'd need to wrap it in a closure first, which defeats the readability gain. - Conditional providers: If some providers only render in certain environments, a static array doesn't express that. Use JSX for conditional trees.
- Server components: This utility creates client components. If your layout is a server component, the composed result forces a client boundary.
For providers that need props, a hybrid approach works — compose the zero-config providers and nest the configured ones manually:
const Providers = composeProviders([ThemeProvider, ToastProvider]);
export default function AppContext({ children }: WithChildren) {
return (
<QueryProvider client={queryClient}>
<Providers>{children}</Providers>
</QueryProvider>
);
}The Performance Win You Get for Free
This pattern pairs naturally with splitting a monolithic provider into focused ones. If you have a single context with 15 properties, every state change re-renders every consumer. Splitting into 3 focused contexts means a theme change only re-renders theme consumers — not modal or toast consumers.
The composition pattern makes this split painless to manage. Without it, going from 1 provider to 5 means adding 4 levels of nesting. With it, you add 4 lines to an array.
I documented the full refactor — splitting a 153-line GlobalProvider into three focused providers — in the AppContext simplification case study. The composeProviders utility was the finishing touch that made the split maintainable.
TypeScript Details
The type constraint is minimal — each provider just needs to accept children:
type WithChildren = { children: React.ReactNode };
const composeProviders = (providers: ComponentType<WithChildren>[]) => ...This means any standard provider component works. If you have providers with additional required props, TypeScript will catch the error at the call site — you can't pass QueryProvider (which requires a client prop) into the array without a type error.
The Full Pattern
Here's the complete, copy-paste-ready version:
import { ComponentType, ReactNode } from 'react';
type WithChildren = { children: ReactNode };
export const composeProviders = (providers: ComponentType<WithChildren>[]) =>
providers.reduce((Acc, Curr) => {
const Composed = ({ children }: WithChildren) => (
<Acc>
<Curr>{children}</Curr>
</Acc>
);
Composed.displayName =
`${Acc.displayName ?? Acc.name}(${Curr.displayName ?? Curr.name})`;
return Composed;
});Eight lines. No dependencies. Works with any React provider that accepts children. The next time you catch yourself adding a seventh level of provider nesting, try this instead.