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.
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:
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
useDebouncedSearchselbst als auch die innen aufgerufenen Hooks müssen am Anfang der Funktion stehen — nicht inif,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.
// ❌ 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:
// 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:
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:
// ❌ 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:
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.
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:
- End-to-End testen — den äußersten Hook aufrufen, gegen den finalen Output asserten. Robust gegen Refactoring der inneren Hooks.
- 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
| Pattern | Beispiel | Wann |
|---|---|---|
| Wrapper: Hook A umhüllt Hook B | useAuthToken → useLocalStorage | Spezialisieren — A ist die App-spezifische Sicht auf B |
| Pipeline: Hooks in Reihe | useDebounce → useFetch | Daten-Transformation — B verarbeitet Output von A |
| Aggregation: mehrere Hooks zu einem | useUserContext aus useUser + usePerms | Zusammenfü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
- Reusing Logic with Custom Hooks – react.dev
- Rules of Hooks – react.dev
- renderHook – Testing Library
- TanStack Query