Das Provider Pattern ist eines der unsichtbarsten und gleichzeitig allgegenwärtigsten Patterns in modernen React-Apps. Jede React-Anwendung von nicht-trivialer Größe hat sie: einen Stapel von Providern im Root-Komponentenbaum — <ThemeProvider>, <I18nProvider>, <AuthProvider>, <QueryClientProvider>, <RouterProvider> —, die je einen Aspekt der Anwendung kapseln und ihn als Context-Konsum überall verfügbar machen. Hinter jedem dieser Provider steckt dasselbe Muster: eine Komponente, die einen Context erzeugt, internen State (oder Service-Instanzen) hält, und ihren Kindern den State plus einen Satz Funktionen über Context bereitstellt. Dieser Artikel zeigt, wie man eigene Provider baut, wie man sie sauber komponiert, wie man die typische „Provider-Pyramide" im Root vermeidet, und wo die Grenzen des Patterns liegen.

Was das Pattern leistet

Ein Provider ist eine Komponente, die zwei Dinge bündelt:

  1. Eine Daten- oder Service-Quelle — z.B. das aktuelle Theme, der eingeloggte User, eine Übersetzungs-Funktion, ein QueryClient.
  2. Die Bereitstellung dieser Quelle über React Context — alle Kinder im Baum können sie ohne Prop-Drilling lesen.

Aus Sicht der App ist ein Provider ein selbst-enthaltenes Modul: man importiert ihn, hängt ihn ins JSX, und die untere App nutzt einen passenden Hook (useTheme(), useAuth()), um an die Daten zu kommen. Die innere Komplexität — wie der State verwaltet wird, wann gefetcht wird, wie Cache-Invalidation aussieht — bleibt im Provider gekapselt.

TypeScript Provider-Schema.jsx
// Schema eines Providers
const SomeContext = createContext(null);

export function SomeProvider({ children }) {
    const [state, setState] = useState(initialState);
    // Logik, Effekte, Service-Calls hier
    return (
        <SomeContext.Provider value={{ state, setState }}>
            {children}
        </SomeContext.Provider>
    );
}

export function useSome() {
    const ctx = useContext(SomeContext);
    if (!ctx) throw new Error('useSome muss innerhalb von <SomeProvider> verwendet werden');
    return ctx;
}

Beispiel — <ThemeProvider>

Ein vollständiges, realistisches Provider-Modul: ein Theme-Provider, der Hell-/Dunkel-Modus verwaltet, die User-Präferenz in localStorage persistiert, und auf System-Präferenz-Änderungen reagiert.

TypeScript ThemeProvider.jsx
import { createContext, useContext, useState, useEffect, useCallback } from 'react';

const ThemeContext = createContext(null);
const STORAGE_KEY = 'theme';

function getInitialTheme() {
    if (typeof window === 'undefined') return 'light';
    const stored = window.localStorage.getItem(STORAGE_KEY);
    if (stored === 'light' || stored === 'dark') return stored;
    return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}

export function ThemeProvider({ children }) {
    const [theme, setTheme] = useState(getInitialTheme);

    // Persistenz + Body-Attribut
    useEffect(() => {
        window.localStorage.setItem(STORAGE_KEY, theme);
        document.documentElement.setAttribute('data-theme', theme);
    }, [theme]);

    // Auf System-Präferenz reagieren, wenn der User keine eigene Wahl getroffen hat
    useEffect(() => {
        const mql = window.matchMedia('(prefers-color-scheme: dark)');
        const handler = (e) => {
            if (!window.localStorage.getItem(STORAGE_KEY)) {
                setTheme(e.matches ? 'dark' : 'light');
            }
        };
        mql.addEventListener('change', handler);
        return () => mql.removeEventListener('change', handler);
    }, []);

    const toggle = useCallback(() => {
        setTheme(t => (t === 'light' ? 'dark' : 'light'));
    }, []);

    const value = { theme, setTheme, toggle };

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

export function useTheme() {
    const ctx = useContext(ThemeContext);
    if (!ctx) throw new Error('useTheme muss innerhalb von <ThemeProvider> verwendet werden');
    return ctx;
}

Anwendung:

TypeScript App.jsx
import { ThemeProvider, useTheme } from './ThemeProvider';

function Header() {
    const { theme, toggle } = useTheme();
    return (
        <header>
            <button onClick={toggle}>
                Theme: {theme}
            </button>
        </header>
    );
}

export default function App() {
    return (
        <ThemeProvider>
            <Header />
            {/* Rest der App */}
        </ThemeProvider>
    );
}

Was den ThemeProvider zu einem „guten" Provider macht:

  • Selbst-enthalten: Alles, was mit Theme zu tun hat — Storage, System-Sync, Toggle — lebt in einer Datei.
  • Hook + Provider als Paar: Konsumenten müssen useTheme() nicht selbst aus dem Context bauen, sondern bekommen ihn fertig.
  • Defensive Fehlermeldung: Wenn useTheme außerhalb des Providers gerufen wird, gibt es eine klare Meldung — nicht nur null.theme is not defined.
  • Stabile Callback-Identität: toggle ist mit useCallback memoisiert — Konsumenten, die das in Props oder Dependency-Arrays nutzen, bekommen keine unnötigen Re-Renders.

Das Problem — die Provider-Pyramide

In einer realistischen App häufen sich Provider schnell an. Das Root sieht dann typischerweise so aus:

TypeScript ProviderPyramide.jsx
export default function App() {
    return (
        <ErrorBoundary>
            <QueryClientProvider client={queryClient}>
                <ThemeProvider>
                    <I18nProvider>
                        <AuthProvider>
                            <NotificationProvider>
                                <RouterProvider router={router}>
                                    <MainLayout />
                                </RouterProvider>
                            </NotificationProvider>
                        </AuthProvider>
                    </I18nProvider>
                </ThemeProvider>
            </QueryClientProvider>
        </ErrorBoundary>
    );
}

Funktional ist das kein Problem — alle Provider laufen, die App funktioniert. Lesbar ist es nicht: jeder neue Provider erhöht die Einrückung. Bei Code-Reviews springen Augen hin und her, um zu sehen, in welchem Provider man gerade ist.

Lösung 1 — Provider-Komposition als Helper

Ein kleiner Helper reduziert die Pyramide zu einer Liste:

TypeScript composeProviders.jsx
// Helper: nimmt eine Liste von Providern (jeweils mit Konfig) und faltet sie zu einem Baum
function composeProviders(providers) {
    return function Compose({ children }) {
        return providers.reduceRight(
            (acc, [Provider, props]) => <Provider {...props}>{acc}</Provider>,
            children
        );
    };
}

// Verwendung
const AppProviders = composeProviders([
    [ErrorBoundary, {}],
    [QueryClientProvider, { client: queryClient }],
    [ThemeProvider, {}],
    [I18nProvider, { locale: 'de' }],
    [AuthProvider, {}],
    [NotificationProvider, {}],
    [RouterProvider, { router }],
]);

export default function App() {
    return (
        <AppProviders>
            <MainLayout />
        </AppProviders>
    );
}

Wie das funktioniert: reduceRight baut den Baum von innen nach außen auf. Der erste Provider in der Liste ist die äußerste Schicht — wichtig zum Verstehen der Mount-Reihenfolge: ErrorBoundary mountet zuerst, RouterProvider zuletzt. Die Reihenfolge ist relevant: Provider, die andere brauchen (z.B. RouterProvider braucht QueryClientProvider, wenn Loader Queries machen), müssen weiter innen stehen.

Lösung 2 — Eigener <AppProviders>-Wrapper

Eine andere Variante: man bündelt alle App-Provider in einer dedizierten Komponente. Das ist weniger generisch, aber lesbarer und besser typisierbar:

TypeScript AppProviders.jsx
// Eine eigene Komponente, die die ganze Provider-Schicht kapselt
export function AppProviders({ children }) {
    return (
        <ErrorBoundary>
            <QueryClientProvider client={queryClient}>
                <ThemeProvider>
                    <I18nProvider locale="de">
                        <AuthProvider>
                            <NotificationProvider>
                                {children}
                            </NotificationProvider>
                        </AuthProvider>
                    </I18nProvider>
                </ThemeProvider>
            </QueryClientProvider>
        </ErrorBoundary>
    );
}

// Anwendung
export default function App() {
    return (
        <AppProviders>
            <RouterProvider router={router}>
                <MainLayout />
            </RouterProvider>
        </AppProviders>
    );
}

In großen Apps ist das oft der pragmatischere Weg: man hat eine eigene Datei AppProviders.tsx, die alle Wrapping-Schichten verwaltet, und der Root ist sauber. Tests, Storybook und SSR-Setup können denselben Wrapper nutzen und kriegen so automatisch alle Provider mit.

Beispiel — <AuthProvider> mit Async-Initialisierung

Ein realistischeres Provider-Beispiel: ein Auth-Provider, der den User beim Mount aus einer Session-Cookie validiert, und Login/Logout-Funktionen bereitstellt. Das illustriert den Umgang mit Async-Init und Loading-Zuständen.

TypeScript AuthProvider.jsx
import { createContext, useContext, useState, useEffect, useCallback } from 'react';

const AuthContext = createContext(null);

export function AuthProvider({ children }) {
    // null = noch nicht initialisiert, false = nicht eingeloggt, Object = User
    const [user, setUser] = useState(null);
    const [isInitializing, setInitializing] = useState(true);

    // Session beim Mount validieren
    useEffect(() => {
        let cancelled = false;
        fetch('/api/me', { credentials: 'include' })
            .then(r => (r.ok ? r.json() : null))
            .then(u => {
                if (!cancelled) {
                    setUser(u);
                    setInitializing(false);
                }
            })
            .catch(() => {
                if (!cancelled) {
                    setUser(null);
                    setInitializing(false);
                }
            });
        return () => { cancelled = true; };
    }, []);

    const login = useCallback(async (email, password) => {
        const res = await fetch('/api/login', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ email, password }),
            credentials: 'include',
        });
        if (!res.ok) throw new Error('Login fehlgeschlagen');
        const u = await res.json();
        setUser(u);
        return u;
    }, []);

    const logout = useCallback(async () => {
        await fetch('/api/logout', { method: 'POST', credentials: 'include' });
        setUser(null);
    }, []);

    const value = { user, isInitializing, login, logout };

    return (
        <AuthContext.Provider value={value}>
            {children}
        </AuthContext.Provider>
    );
}

export function useAuth() {
    const ctx = useContext(AuthContext);
    if (!ctx) throw new Error('useAuth muss innerhalb von <AuthProvider> verwendet werden');
    return ctx;
}

Konsum mit Loading-Behandlung:

TypeScript ProtectedRoute.jsx
function ProtectedRoute({ children }) {
    const { user, isInitializing } = useAuth();

    if (isInitializing) return <FullPageSpinner />;
    if (!user) return <Navigate to="/login" replace />;
    return children;
}

Wichtig: isInitializing ist separat vom user. So kann der Konsument zwischen „Session-Check läuft noch" und „User ist nicht eingeloggt" unterscheiden — sonst würde die App beim ersten Render fälschlich zur Login-Seite weiterleiten.

Stolperfallen

Re-Render bei jedem Render des Providers

Wenn das value-Object inline erzeugt wird (<Ctx.Provider value={{ user, login }}>), ist es bei jedem Render eine neue Referenz — alle Konsumenten rendern neu, auch wenn sich nichts geändert hat. Lösung: das Object memoisieren:

TypeScript ValueMemo.jsx
const value = useMemo(() => ({ user, login, logout }), [user, login, logout]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;

Setze auch die Callbacks (login, logout) mit useCallback, sonst kippt die Memo-Identität bei jedem Render.

Context-Splitting für selten ändernde Setter

Wenn ein Provider sowohl schnell wechselnden State (z.B. notifications) als auch stabile Funktionen (z.B. addNotification, removeNotification) bietet, lohnt es sich, zwei Contexts anzulegen — einen für State, einen für Setter. Konsumenten, die nur senden wollen (nicht abonnieren), abonnieren dann nur den stabilen Setter-Context und re-rendern nicht bei jedem State-Update. Siehe Performance-Fallstricke bei Context.

Provider in der falschen Reihenfolge

Wenn RouterProvider außerhalb von QueryClientProvider steht, aber Router-Loader Queries benutzen wollen, schlägt es zur Laufzeit fehl. Provider-Reihenfolge ist nicht beliebig: was inneren Providern hilft, muss außen stehen. Beim Refactoring darauf achten.

null als Default-Wert + Hook-Helper

Setze den Context-Default auf null und prüfe im Custom-Hook (useTheme, useAuth), ob ein Provider drumherum existiert — anstatt einen plausiblen Fake-Default zu liefern. Das ist robuster: ein vergessener Provider gibt einen klaren Fehler statt eines kaputten Verhaltens. Genau dieses Pattern verwenden Radix, React Query und die meisten guten UI-Libraries.

Provider = Context + Logik in einem Modul

Ein Provider ist die selbst-enthaltene Auslieferung eines App-Aspekts: Context, State, Lifecycle und Service-Funktionen leben in einer Datei und werden als Provider-Komponente + Hook-Helper exportiert.

Provider und Hook gehören als Paar exportiert

Exportiere immer beides — <ThemeProvider> und useTheme(). Konsumenten sollen nicht den nackten Context importieren müssen; der Hook kapselt auch die „Provider-Check"-Logik.

Defensive Fehlermeldung im Hook

Wenn der Hook außerhalb seines Providers gerufen wird, soll er klar werfen: throw new Error('useX muss innerhalb von <XProvider> verwendet werden'). Verhindert Stunden von Debugging gegen null.something is not defined.

Provider-Pyramide löst man mit composeProviders oder eigenem Wrapper

Tief verschachtelte Provider-Stacks im Root lassen sich entweder mit einem composeProviders-Helper auflösen (Liste statt Baum) oder einem eigenen <AppProviders>-Wrapper, der die Schichten kapselt. Letzteres ist bei großen Apps oft pragmatischer und besser typisierbar.

Reihenfolge ist relevant

Provider, die von anderen abhängen (Router-Loader brauchen QueryClient, Auth braucht NotificationProvider für Auth-Errors), müssen innen stehen. Beim Refactoring der Provider-Liste die Reihenfolge bewusst prüfen.

`value` memoisieren oder neu re-rendern alle Konsumenten

Inline-erzeugte value-Objects bei <Ctx.Provider value={{ … }}> sind bei jedem Render neue Referenzen. Mit useMemo plus useCallback für die Funktionen wird das stabil — Konsumenten rendern nur noch bei echten Daten-Änderungen.

Async-Init braucht ein eigenes `isInitializing`-Flag

Provider, die beim Mount Daten holen (Auth, Feature-Flags), sollten zwischen „läuft noch" und „fertig, aber leer" unterscheiden. Konsumenten brauchen das Flag, um nicht voreilig zum Login zu redirecten oder leere UI zu zeigen.

Provider sind nicht für jeden Anwendungsfall

Für app-weite Daten (Theme, Auth, Locale, Feature-Flags) sind Provider ideal. Für lokalen State (Form-Felder, Toggle-States in einer Liste) ist Component-State direkt besser — Context-Provider haben Overhead und re-rendern weite Teile des Baums.

Verwandte Artikel

Externe Quellen

/ Weiter

Zurück zu Advanced Patterns

Zur Übersicht