Skip to main content

Composing React Providers Without the Nesting Nightmare

Colorful interlocking puzzle pieces on a white surface
Apr 5, 20262 min readReact, TypeScript, Performance

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 exact shape:

The nesting problem
<ThemeProvider>
  <AuthProvider>
    <ToastProvider>
      <ModalProvider>
        <AnalyticsProvider>
          <FeatureFlagProvider>{children}</FeatureFlagProvider>
        </AnalyticsProvider>
      </ModalProvider>
    </ToastProvider>
  </AuthProvider>
</ThemeProvider>

Every new provider tacks on another level of indentation, the ordering matters but isn't obvious from looking, and adding, removing, or reordering providers means carefully surgery on nested JSX. It's a maintenance tax that scales linearly with your provider count.

The pattern

Here's a composeProviders utility that flattens the nesting into a plain list:

composeProviders.ts
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:

AppContext.tsx
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, and the result is functionally identical to hand-written nesting; it produces the same component tree at runtime, with no overhead, no extra renders, and no magic.

Display names are automatic

The displayName assignment means React DevTools shows you the full provider chain:

ThemeProvider(ToastProvider(ModalProvider(...)))

Without it, composed components show up as Anonymous in the tree, which makes debugging miserable. The ?? Acc.name fallback covers providers that don't set displayName explicitly.

Order is explicit and scannable

Provider order matters, since inner providers can consume the contexts of outer ones. In nested JSX that ordering is buried in the indentation hierarchy; in the array it's a flat list you read top to bottom, and 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 to fix, no brackets to match, no risk of accidentally nesting it in the wrong spot.

When NOT to use this

This pattern works well for app-level providers that wrap your whole tree: theme, auth, toast, modals. It's a poor fit for a few cases:

  • Providers with custom props: if a provider needs configuration (<QueryProvider client={queryClient}>), you can't pass it as a bare component reference; you'd have to wrap it in a closure first, which throws away the readability gain.
  • Conditional providers: if some providers only render in certain environments, a static array can't express that, so reach for JSX on conditional trees.
  • Server components: this utility creates client components, so if your layout is a server component, the composed result forces a client boundary on you.

For providers that need props, a hybrid approach holds up well: compose the zero-config providers and nest the configured ones by hand.

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 carrying 15 properties, every state change re-renders every consumer; splitting it into 3 focused contexts means a theme change only re-renders the theme consumers, not the modal or toast ones.

The composition pattern makes that split painless to live with. Without it, going from 1 provider to 5 means adding 4 levels of nesting; with it, you add 4 lines to an array and move on.

I wrote up 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 about as minimal as it gets: each provider just needs to accept children.

type WithChildren = { children: React.ReactNode };
const composeProviders = (providers: ComponentType<WithChildren>[]) => ...

That means any standard provider component slots right in, and if you have providers with extra required props, TypeScript catches it at the call site; you can't drop QueryProvider (which requires a client prop) into the array without a type error.

The full pattern

Here's the complete, copy-paste-ready version:

composeProviders.ts
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, and it works with any React provider that accepts children. Next time you catch yourself adding a seventh level of provider nesting, reach for this instead.