Die wahre Stärke von Custom Hooks zeigt sich nicht in einem einzelnen Hook, sondern in ihrer Zusammensetzbarkeit: ein Custom Hook ruft mehrere andere Custom Hooks intern auf und kapselt damit eine größere Geschäfts-Logik in einer Funktion. So entstehen Domain-Hooks wie useDebouncedSearch, useAuthenticatedUser, useOptimisticCart — jeder eine Komposition aus kleineren Bausteinen wie useDebounce, useFetch, useLocalStorage. Dieses Pattern ist React's elegante Antwort auf das alte „wie teile ich komplexe Logik zwischen Komponenten"-Problem. HOCs verschachteln, Render Props sind verschachteln-in-JSX, Komposition ist verschachteln-in-Funktion — ohne Wrapper-Hell, ohne Render-Prop-Pyramide.

Das Grundprinzip: Hook ruft Hook

Ein Custom Hook darf intern beliebige andere Hooks aufrufen — eingebaute (useState, useEffect) oder eigene (useDebounce, useFetch). Genau das macht Komposition so direkt: keine spezielle API, keine Wrapper-Konstrukte, einfach Funktions-Aufrufe.

TypeScript HookRuftHook.js
function useDebouncedSearch(query, delay = 300) {
    // 1. Debounced-Wert holen
    const debouncedQuery = useDebounce(query, delay);

    // 2. Mit dem debounced Wert die API ansprechen
    const { data, isLoading, error } = useFetch(
        debouncedQuery
            ? `/api/search?q=${encodeURIComponent(debouncedQuery)}`
            : null
    );

    // 3. Kombinierte API zurückgeben
    return {
        results: data?.items ?? [],
        isLoading,
        error,
        isDebouncing: query !== debouncedQuery,
    };
}

Drei eingebaute Hooks (useState, useEffect, useState über die Custom Hooks) und zwei Custom Hooks (useDebounce, useFetch) ergeben einen fertigen Such-Hook, den Komponenten direkt nutzen können:

TypeScript DebouncedSearchKonsument.jsx
function SearchPage() {
    const [query, setQuery] = useState('');
    const { results, isLoading, error, isDebouncing } = useDebouncedSearch(query);

    return (
        <>
            <input value={query} onChange={(e) => setQuery(e.target.value)} />
            {isDebouncing && <p>Tippen …</p>}
            {isLoading && <p>Lade Ergebnisse …</p>}
            {error && <p>Fehler: {error.message}</p>}
            <ul>
                {results.map(r => <li key={r.id}>{r.title}</li>)}
            </ul>
        </>
    );
}

Die Komponente kümmert sich nur ums Rendern. Debouncing, API-Calls, Race-Conditions, Loading-State — alles im Hook. Trennung von „was" und „wie" auf einer Funktions-Grenze.

Die Hook-Regeln gelten transitiv

Wenn useDebouncedSearch intern useDebounce und useFetch aufruft, gelten die Hook-Regeln auf allen Ebenen:

  • Top-Level: sowohl useDebouncedSearch selbst als auch die innen aufgerufenen Hooks müssen am Anfang der Funktion stehen — nicht in if, for, try/catch, nicht nach Early-Return.
  • Reihenfolge konstant: beim ersten Render werden die inneren Hooks in einer bestimmten Reihenfolge ausgeführt. Diese Reihenfolge muss bei jedem Re-Render identisch sein.
TypeScript ReihenfolgeWichtig.js
// ❌ Conditional Hook-Aufruf im Inneren — VERLETZT die Regeln
function useDebouncedSearch(query, options = {}) {
    const debouncedQuery = useDebounce(query, options.delay);

    if (options.enabled) {
        const result = useFetch(`/api/search?q=${debouncedQuery}`);   // FEHLER
        return result;
    }
    return null;
}

// ✅ Hook IMMER aufrufen, Conditional Logik im Result
function useDebouncedSearch(query, options = {}) {
    const debouncedQuery = useDebounce(query, options.delay);

    // useFetch sich um null-URL kümmern lassen — Hook wird IMMER aufgerufen
    const result = useFetch(
        options.enabled && debouncedQuery
            ? `/api/search?q=${debouncedQuery}`
            : null
    );
    return result;
}

Der useFetch-Hook muss intern null-URLs als „nicht fetchen" interpretieren — das ist eine bewusste API-Entscheidung, die ihn komposabel macht. Ohne dieses Conditional-via-Input-Pattern müsste man useFetch immer aufrufen und das Ergebnis im Konsumenten ignorieren, was Verschwendung wäre.

Aufbau in Schichten

Komposition lädt zum Schichten ein. Ein konkretes Beispiel — ein Authenticated-User-Hook:

TypeScript UseAuthenticatedUser.js
// SCHICHT 1: dünne Wrapper um Browser-APIs
function useLocalStorage(key, initial) { /* aus dem localStorage-Artikel */ }
function useFetch(url) { /* aus dem useFetch-Artikel */ }

// SCHICHT 2: domain-spezifische Bausteine
function useAuthToken() {
    return useLocalStorage('auth-token', null);
}

function useCurrentUserId() {
    const [token] = useAuthToken();
    // JWT decoden, sub-Claim extrahieren — Helper-Funktion außerhalb des Hooks
    return token ? decodeJwt(token).sub : null;
}

// SCHICHT 3: zusammenführender Application-Hook
function useAuthenticatedUser() {
    const userId = useCurrentUserId();
    const { data, isLoading, error } = useFetch(
        userId ? `/api/users/${userId}` : null
    );

    return {
        user: data ?? null,
        isAuthenticated: !!userId,
        isLoading,
        error,
    };
}

Drei Schichten, klare Verantwortlichkeiten:

  • Schicht 1: tooling-nahe Hooks, die mit Browser-APIs interagieren. Universell wiederverwendbar.
  • Schicht 2: domain-spezifisch — sie kennen die App (z.B. wo der Auth-Token liegt, was ein JWT ist). Lokaler Scope.
  • Schicht 3: ein zusammenführender Use-Case-Hook, den Komponenten direkt brauchen. „Gib mir den aktuellen User."

Die Komponente nutzt nur Schicht 3 — sie kennt weder localStorage noch JWT noch Fetch-Details:

TypeScript ProfileSection.jsx
function ProfileSection() {
    const { user, isAuthenticated, isLoading } = useAuthenticatedUser();

    if (!isAuthenticated) return <LoginPrompt />;
    if (isLoading) return <Spinner />;
    return <h2>Hi, {user.name}</h2>;
}

Wenn morgen der Auth-Mechanismus wechselt (z.B. Cookie statt localStorage, OAuth statt JWT), ändert sich nur Schicht 2. Die Komponente und die useAuthenticatedUser-API bleiben unberührt. Das ist Komposition als Architektur-Werkzeug.

Komposition vs. Mega-Hook

Es ist verlockend, alles in einen großen Hook zu stopfen — „der macht halt alles". Schlechte Idee:

TypeScript MegaHookAntiPattern.js
// ❌ Ein Mega-Hook, der alles macht
function useEverything() {
    const [user, setUser] = useState(null);
    const [token, setToken] = useState(localStorage.getItem('token'));
    const [searchQuery, setSearchQuery] = useState('');
    const [searchResults, setSearchResults] = useState([]);
    const [theme, setTheme] = useState('light');
    // … 200 Zeilen, 15 useState, 8 useEffect

    return { /* 30 Properties */ };
}

Probleme:

  • Unüberschaubar.
  • Jede Komponente, die useEverything() aufruft, rendert bei JEDER Änderung im Hook neu — auch wenn sie nur einen Teil davon nutzt.
  • Nicht testbar in Einheiten.
  • Nicht wiederverwendbar — keine Teile rauslösbar.

Faustregel zur Granularität:

  • Ein Hook hat einen Namen — wenn der Name nicht in 5 Sekunden formuliert werden kann, ist der Hook zu groß.
  • Ein Hook deckt ein Konzept ab — Auth, Search, Form-Validation. Nicht „User-Logik und Search-Logik und Theme-Logik".
  • Ein Hook hat 5-15 Zeilen Body als Daumenregel. Längere Hooks sind Kandidaten zum Aufspalten.

Zustands-Übergabe zwischen Hooks

Manchmal soll Hook A den Output von Hook B verarbeiten. Das ist genau Komposition:

TypeScript KettenKomposition.js
function useUserPreferences() {
    const userId = useCurrentUserId();
    const { data: prefs } = useFetch(
        userId ? `/api/users/${userId}/preferences` : null
    );
    return prefs ?? defaultPrefs;
}

function useThemedColors() {
    const prefs = useUserPreferences();
    // theme aus User-Preferences ableiten
    return prefs.darkMode ? darkPalette : lightPalette;
}

function useAccentColor() {
    const colors = useThemedColors();
    return colors.accent;
}

Drei Schichten in Kette: User → Preferences → Theme → Accent. Jede Komponente, die nur die Accent-Farbe braucht, ruft useAccentColor() — und bekommt automatisch die ganze Kette dahinter. Bei Änderungen an User-Preferences propagieren die Werte durch alle Schichten.

Wichtig — wann das nicht skaliert: Wenn drei Komponenten dieselben Daten brauchen, machen drei useUserPreferences()-Aufrufe drei separate Requests. Da ist eine zentrale Cache-Library (TanStack Query) die richtige Lösung — Komposition allein deckt das Caching-Problem nicht ab.

Komposition als Test-Strategie

Komponierbare Hooks lassen sich isoliert testen — jede Schicht hat klare Eingänge und Ausgänge.

TypeScript HookTest.js
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

test('inkrementiert korrekt', () => {
    const { result } = renderHook(() => useCounter(0));

    expect(result.current.count).toBe(0);

    act(() => {
        result.current.increment();
    });

    expect(result.current.count).toBe(1);
});

test('respektiert die Schrittweite', () => {
    const { result } = renderHook(() => useCounter(0, 5));

    act(() => {
        result.current.increment();
    });

    expect(result.current.count).toBe(5);
});

renderHook aus @testing-library/react erzeugt eine Test-Komponente, ruft den Hook auf und exposed das Ergebnis. Mit act(() => ...) werden Aktionen ausgeführt — der Wrapper sorgt dafür, dass Re-Renders synchron abgeschlossen sind, bevor wir asserten.

Bei komponierten Hooks (Hook A nutzt Hook B) bietet renderHook zwei Strategien:

  1. End-to-End testen — den äußersten Hook aufrufen, gegen den finalen Output asserten. Robust gegen Refactoring der inneren Hooks.
  2. Mit Mocks — die inneren Hooks per Jest/Vitest mocken und nur die Komposition selbst testen. Schneller, aber brüchiger.

Faustregel: End-to-End für die meisten Cases. Mocks nur für teure Calls (Fetch).

Drei Komposition-Patterns im Vergleich

PatternBeispielWann
Wrapper: Hook A umhüllt Hook BuseAuthTokenuseLocalStorageSpezialisieren — A ist die App-spezifische Sicht auf B
Pipeline: Hooks in ReiheuseDebounceuseFetchDaten-Transformation — B verarbeitet Output von A
Aggregation: mehrere Hooks zu einemuseUserContext aus useUser + usePermsZusammenführen — eine API für mehrere Quellen

In der Praxis mischt man die Patterns frei. useAuthenticatedUser aus dem früheren Beispiel ist eine Pipeline (Token → ID → Fetch) UND eine Aggregation (mehrere State-Slots als ein Object zurück).

Besonderheiten

Hook-Komposition ist React's Antwort auf das HOC/Render-Props-Problem.

Pre-Hooks musste man HOCs verschachteln (Wrapper-Hell) oder Render Props ineinander setzen (Render-Pyramide). Komposition löst beides durch normale Funktions-Aufrufe — kein zusätzliches Konstrukt nötig.

Hook-Regeln gelten transitiv auf allen Komposition-Ebenen.

Wenn useA intern useB aufruft, muss useA selbst auf Top-Level einer Komponente aufgerufen werden — und intern muss useB auf Top-Level von useA stehen. Keine bedingten Aufrufe in keiner Ebene.

Conditional Logik geht durch null-Argumente, nicht durch bedingten Hook-Aufruf.

useFetch(null) als „nicht fetchen" — ein bewusstes API-Design, das den Hook komposabel macht. Konsumenten können den Hook IMMER aufrufen und über Argumente steuern, ob er etwas tut.

Schichten-Design: tooling → domain → use-case.

Schicht 1: dünne Browser-API-Wrapper (useLocalStorage, useFetch). Schicht 2: domain-Bausteine (useAuthToken, useCurrentUserId). Schicht 3: use-case-Hooks für Komponenten (useAuthenticatedUser). Komponenten reden nur mit Schicht 3.

Mega-Hook ist Anti-Pattern — gemeinsame Granularität wahren.

Ein Hook deckt EIN Konzept ab. Wer „macht alles" hört, sollte aufspalten. Faustregel: 5-15 Zeilen Body, ein Name, ein Konzept.

Komposition löst das Architektur-Problem, nicht das Caching-Problem.

Wenn drei Komponenten denselben Domain-Hook nutzen, macht jeder Call einen eigenen API-Request. Dafür braucht es ein Caching-Layer wie TanStack Query. Komposition + Cache = vollständige Lösung.

Tests mit renderHook aus `@testing-library/react`.

Hooks lassen sich isoliert testen. renderHook(() => useCounter()) erzeugt eine Test-Komponente, result.current gibt die Rückgabe, act wickelt Aktionen sauber ab. End-to-End-Tests sind robuster als Hook-Mocking.

Drei Patterns: Wrapper, Pipeline, Aggregation.

Wrapper spezialisiert einen Hook für den App-Kontext. Pipeline reicht Daten durch eine Reihe von Hooks. Aggregation kombiniert mehrere Hooks zu einer API. In der Praxis Mischformen.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Custom Hooks

Zur Übersicht