Custom Hook Extraction
Komponenten, die gleichzeitig Datenfetching, Formularvalidierung und UI-State verwalten, sind schwer zu testen, schwer zu warten und schwer wiederzuverwenden. Custom Hook Extraction ist das fundamentale React-Pattern, das Logik von der Darstellung trennt – und damit beides besser macht.
Inhaltsverzeichnis
- 1. Warum Custom Hook Extraction ein Fundamentalmuster ist
- 2. Anatomie eines Custom Hooks
- 3. Pattern: Data-Fetching-Hooks
- 4. Pattern: Formular-Logik-Hooks
- 5. Pattern: Event- und Browser-API-Hooks
- 6. Performance: useCallback und useMemo richtig einsetzen
- 7. Custom Hooks mit TypeScript vollständig typisieren
- 8. Testing-Strategie: renderHook und Mocking
- 9. Wann Hooks, wann andere Patterns?
- 10. Zusammenfassung
- 11. FAQ
1. Warum Custom Hook Extraction ein Fundamentalmuster ist
Die Custom Hook Extraction ist das grundlegendste aller React Design Patterns, weil sie das Einzelverantwortungsprinzip direkt auf React-Komponenten anwendet. Eine Komponente, die gleichzeitig Datenfetching, Formularvalidierung, Error-Handling und UI-State verwaltet, verletzt das SRP auf die denkbar offensichtlichste Weise. Der Code wird unlesbar, Logik ist nicht testbar ohne das gesamte Rendering zu starten, und dieselbe Logik wird in ähnlichen Komponenten kopiert statt geteilt.
Der Ausweg ist die Extraktion der Logik in Custom Hooks: Funktionen, die mit use beginnen, andere Hooks verwenden dürfen und einen Wert zurückgeben. Custom Hooks sind reguläre TypeScript-Funktionen, die zufällig Hooks verwenden – das macht sie mit renderHook isoliert testbar, in anderen Komponenten und Projekten wiederverwendbar und durch einfaches Lesen der Signatur verständlich. Die Komponente selbst wird zur reinen View: Sie ruft Hooks auf, gibt Daten an JSX weiter und handhabt User-Events.
Ein realistisches Maß für den Erfolg der Extraktion: Nach der Umstrukturierung sollte die Komponente keine useEffect-Aufrufe mehr enthalten (diese gehören in spezifische Hooks), keine komplexen useMemo- oder useCallback-Blocks (diese gehören in den Hook, der die Berechnung besitzt) und idealerweise unter dreißig Zeilen JSX bleiben. Das ist kein dogmatisches Ziel, aber ein guter Indikator dafür, dass die Logik-Darstellung-Trennung gelungen ist.
2. Anatomie eines Custom Hooks
Ein Custom Hook ist eine JavaScript-Funktion, deren Name mit use beginnt und die andere React Hooks aufrufen darf. Die Namenskonvention ist nicht optional – React verlässt sich darauf, um die Rules of Hooks statisch zu prüfen. Ein Custom Hook darf State halten (useState), Seiteneffekte ausführen (useEffect), andere Custom Hooks aufrufen und Werte zurückgeben. Er darf keinen JSX zurückgeben, keine DOM-Manipulation ohne useEffect und keine direkten Seiteneffekte außerhalb von Hooks.
Das Rückgabeformat eines Custom Hooks ist eine wichtige Designentscheidung: Ein Tupel [value, setter] wie bei useState ist ideal, wenn der Hook ein einziges Hauptkonzept kapselt. Ein Objekt { data, isLoading, error, refetch } ist besser, wenn der Hook mehrere unabhängige Werte und Aktionen zurückgibt. Die Faustregel: Wenn der Konsument die Rückgabewerte umbenennen will, ist ein Objekt besser (destructuring mit Umbenennung: const { data: products } = useProducts()). Wenn die Reihenfolge selbstverständlich ist wie bei [value, setValue], ist ein Tupel eleganter.
// use-local-storage.ts — Generic hook with tuple return and full TypeScript inference
import { useState, useCallback, useEffect } from 'react';
type SetValue<T> = (value: T | ((prev: T) => T)) => void;
export function useLocalStorage<T>(key: string, initialValue: T): [T, SetValue<T>, () => void] {
// Initialize from localStorage or fall back to initialValue
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === 'undefined') return initialValue; // SSR guard
try {
const item = localStorage.getItem(key);
return item !== null ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
// Sync to localStorage on every change
const setValue: SetValue<T> = useCallback((value) => {
setStoredValue((prev) => {
const next = typeof value === 'function' ? (value as (p: T) => T)(prev) : value;
try {
localStorage.setItem(key, JSON.stringify(next));
} catch {
console.warn(`useLocalStorage: could not save key "${key}"`);
}
return next;
});
}, [key]);
const removeValue = useCallback(() => {
localStorage.removeItem(key);
setStoredValue(initialValue);
}, [key, initialValue]);
// Cross-tab sync
useEffect(() => {
const handler = (e: StorageEvent) => {
if (e.key === key && e.newValue !== null) {
setStoredValue(JSON.parse(e.newValue) as T);
}
};
window.addEventListener('storage', handler);
return () => window.removeEventListener('storage', handler);
}, [key]);
return [storedValue, setValue, removeValue];
}
// Usage — clean component, no localStorage logic visible
function ThemeToggle() {
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');
return <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>{theme}</button>;
}
3. Pattern: Data-Fetching-Hooks
Data-Fetching-Hooks sind die häufigste Kategorie von Custom Hooks. Sie kapseln den vollständigen Lebenszyklus eines API-Calls: initiales Laden, Loading-State, Daten, Fehlerbehandlung, Abbruch bei Unmount und optionales Re-Fetching. Ohne Extraktion landet dieser Code oft in einem einzelnen useEffect-Block in der Komponente – komplex, schwer zu testen und nicht wiederverwendbar.
Ein gut gestalteter Data-Fetching-Hook gibt mindestens data, isLoading, error und eine refetch-Funktion zurück. Er bricht laufende Requests ab, wenn der Hook unmountet wird (via AbortController). Er memoiziert die Fetch-Funktion mit useCallback, damit Abhängigkeitsarrays in useEffect stabil bleiben. In der Praxis wird für einfaches Datenfetching heute oft React Query (@tanstack/react-query) verwendet – das ist sinnvoll, aber das Verstehen des zugrundeliegenden Patterns ist Voraussetzung für das korrekte Konfigurieren von React Query.
// use-fetch.ts — Generic data-fetching hook with abort and error handling
import { useState, useEffect, useCallback, useRef } from 'react';
interface FetchState<T> {
data: T | null;
isLoading: boolean;
error: Error | null;
refetch: () => void;
}
export function useFetch<T>(url: string, options?: RequestInit): FetchState<T> {
const [data, setData] = useState<T | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [refetchCounter, setRefetchCounter] = useState(0);
const abortControllerRef = useRef<AbortController | null>(null);
const refetch = useCallback(() => setRefetchCounter(c => c + 1), []);
useEffect(() => {
// Abort previous request if URL changes or component unmounts
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
setIsLoading(true);
setError(null);
fetch(url, { ...options, signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
return res.json() as Promise<T>;
})
.then(json => {
setData(json);
setIsLoading(false);
})
.catch(err => {
if (err.name === 'AbortError') return; // Ignore cancellation
setError(err instanceof Error ? err : new Error(String(err)));
setIsLoading(false);
});
return () => controller.abort();
}, [url, refetchCounter]); // options excluded — pass stable object or stringify
return { data, isLoading, error, refetch };
}
// Clean component — no fetch logic, only presentation
function ProductList({ categoryId }: { categoryId: string }) {
const { data: products, isLoading, error, refetch } = useFetch<Product[]>(
`/api/products?category=${categoryId}`
);
if (isLoading) return <Skeleton />;
if (error) return <ErrorMessage message={error.message} onRetry={refetch} />;
return <ul>{products?.map(p => <ProductCard key={p.id} product={p} />)}</ul>;
}
4. Pattern: Formular-Logik-Hooks
Formular-Logik gehört zu den komplexesten Bereichen in React-Anwendungen: Felder haben Werte, Validierungsregeln, Touched-States und Error-Messages. Das alles in einer Komponente zu halten macht sie schnell unhandhabbar. Ein Formular-Hook kapselt diesen State und die zugehörige Logik: Wertänderungen, Validierung beim Blur, Submit-Handling und Reset. Die Komponente selbst bind nur noch die Felder und zeigt Fehler an.
Der Schlüssel eines gut gestalteten Formular-Hooks liegt in der Stabilität der zurückgegebenen Handler-Funktionen: handleChange, handleBlur und handleSubmit sollten mit useCallback memoiziert sein, damit Felder, die diese Funktionen als Props erhalten, nicht bei jedem Re-render neu rendern. In der Praxis ergänzt dieser Ansatz React Hook Form hervorragend: Für komplexe Formulare setzt man React Hook Form ein, für einfache Formulare reicht ein eigener Hook und spart die Abhängigkeit.
5. Pattern: Event- und Browser-API-Hooks
Browser-APIs wie Intersection Observer, ResizeObserver, Media Queries, Geolocation und Clipboard sind in React-Komponenten direkt zu verwenden umständlich und fehleranfällig – Cleanup in useEffect wird vergessen, mehrere Komponenten duplizieren denselben Observer-Code. Custom Hooks lösen dieses Problem elegant: Ein useIntersectionObserver-Hook kapselt die gesamte Observer-Lifecycle, ein useMediaQuery-Hook synchronisiert Media-Query-Ergebnisse mit React-State.
Das wichtigste Detail bei Browser-API-Hooks ist die Cleanup-Funktion in useEffect. Observer müssen abgemeldet, Event-Listener entfernt und Subscriptions beendet werden, wenn der Hook unmountet. Fehlendes Cleanup ist die häufigste Quelle von Memory Leaks in React-Anwendungen. Ein gut getesteter Custom Hook deckt dieses Szenario explizit ab: Der Test prüft, ob nach dem Unmount kein Observer oder Listener mehr aktiv ist.
// use-intersection-observer.ts — Lazy loading hook via IntersectionObserver
import { useEffect, useRef, useState, type RefObject } from 'react';
interface UseIntersectionObserverOptions {
threshold?: number | number[];
rootMargin?: string;
root?: Element | null;
once?: boolean; // stop observing after first intersection
}
export function useIntersectionObserver<T extends Element>(
options: UseIntersectionObserverOptions = {}
): [RefObject<T | null>, boolean] {
const { threshold = 0.1, rootMargin = '0px', root = null, once = false } = options;
const ref = useRef<T>(null);
const [isIntersecting, setIsIntersecting] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element || typeof IntersectionObserver === 'undefined') return;
const observer = new IntersectionObserver(
([entry]) => {
const intersecting = entry.isIntersecting;
setIsIntersecting(intersecting);
// Disconnect after first intersection if once=true
if (intersecting && once) {
observer.disconnect();
}
},
{ threshold, rootMargin, root }
);
observer.observe(element);
// Cleanup: always disconnect to prevent memory leaks
return () => observer.disconnect();
}, [threshold, rootMargin, root, once]);
return [ref, isIntersecting];
}
// use-debounce.ts — Debounce hook for search inputs
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer); // cleanup prevents stale updates
}, [value, delay]);
return debouncedValue;
}
// Clean component combining both hooks
function LazySearchResults({ query }: { query: string }) {
const debouncedQuery = useDebounce(query, 300);
const [containerRef, isVisible] = useIntersectionObserver<HTMLDivElement>({ once: true });
const { data, isLoading } = useFetch<Result[]>(
isVisible && debouncedQuery ? `/api/search?q=${debouncedQuery}` : ''
);
return (
<div ref={containerRef}>
{isVisible && (isLoading ? <Spinner /> : <ResultList items={data ?? []} />)}
</div>
);
}
6. Performance: useCallback und useMemo richtig einsetzen
Ein häufiges Missverständnis bei Custom Hooks ist, dass useCallback und useMemo immer eingesetzt werden sollten. Das Gegenteil ist richtig: Beide Hooks haben Overhead durch den Memoization-Mechanismus, der sich nur lohnt, wenn die Berechnung oder Funktion deutlich teurer ist als das Memoization selbst. Die Daumenregel: useMemo für teure Berechnungen (Filtern großer Arrays, komplexe Transformationen), useCallback für Funktionen, die als Props an optimierte Kindkomponenten (React.memo) weitergegeben werden oder als Abhängigkeiten in useEffect-Arrays stehen.
In Custom Hooks sind useCallback und useMemo häufiger gerechtfertigt als in Komponenten, weil der Hook State und Funktionen zurückgibt, die von außen nicht kontrolliert werden können. Eine refetch-Funktion ohne useCallback erzeugt bei jedem Re-render eine neue Referenz, was alle useEffect-Abhängigkeitsarrays, die diese Funktion enthalten, jedes Mal neu auslöst. Das ist fast immer ein Bug. Innerhalb von Custom Hooks ist useCallback für zurückgegebene Handler-Funktionen daher die Regel, nicht die Ausnahme.
7. Custom Hooks mit TypeScript vollständig typisieren
TypeScript und Custom Hooks ergänzen sich hervorragend, weil Hooks in TypeScript vollständig durch Generics parametrisierbar sind. Ein useFetch<T>-Hook inferiert den Datentyp aus dem Generischen Parameter und gibt data: T | null zurück – der Konsument bekommt vollständige Auto-Vervollständigung für den Datentyp. Das eliminiert die Notwendigkeit für Cast-Operatoren (as Product[]) in den Komponenten.
Rückgabetypen von Custom Hooks sollten explizit annotiert werden, wenn der Hook ein Tupel zurückgibt. TypeScript inferiert Tupel als Arrays, wenn der Rückgabetyp nicht explizit annotiert ist: return [storedValue, setValue] wird als (T | SetValue<T>)[] inferiert, nicht als [T, SetValue<T>]. Das führt zu ungenauen Typen beim Destructuring. Die Lösung: entweder expliziter Rückgabetyp : [T, SetValue<T>] oder return [storedValue, setValue] as const.
8. Testing-Strategie: renderHook und Mocking
renderHook aus @testing-library/react ist das primäre Werkzeug zum Testen von Custom Hooks in Isolation. Es rendert den Hook in einer minimalen React-Umgebung und stellt das Ergebnis für Assertions bereit. Die act-Funktion wickelt State-Updates ein, damit React alle Effekte synchron verarbeitet. Ein Data-Fetching-Hook kann durch Mocken der globalen fetch-Funktion getestet werden, ohne echte Netzwerk-Requests zu machen.
Eine vollständige Test-Suite für einen Custom Hook deckt folgende Szenarien ab: initiales Rendering (initiale State-Werte korrekt), State-Transitions (Aktionen verändern State erwartungsgemäß), Cleanup (kein Memory Leak nach Unmount), Edge Cases (leere Eingaben, Fehler, Null-Werte) und Async-Verhalten (Loading-State, erfolgreicher Request, fehlgeschlagener Request). Diese Testabdeckung ist ohne Custom Hooks kaum möglich, weil die Logik mit dem Rendering verflochten ist.
9. Wann Hooks, wann andere Patterns?
Custom Hooks sind das richtige Werkzeug, wenn wiederverwendbare Logik mit React-State oder -Effekten extrahiert werden soll. Sie sind nicht das richtige Werkzeug für reine Berechnungsfunktionen (dafür reichen reguläre Funktionen), für State-Sharing zwischen nicht verwandten Komponenten (dafür ist Zustand oder Jotai besser) oder für die Aufteilung von Render-Logik auf mehrere Komponenten (dafür sind Compound Components besser).
| Anforderung | Empfohlenes Pattern | Begründung |
|---|---|---|
| Datenfetching wiederverwenden | Custom Hook (useFetch) | State + Effekte gehören zusammen, Test via renderHook |
| State zwischen weit entfernten Komponenten | Zustand / Jotai | Hooks teilen State nur innerhalb einer Komponente |
| Flexible UI-Komposition | Compound Components | Hooks können kein JSX zurückgeben |
| Reine Berechnung ohne State | Reguläre Funktion | Kein Hook-Overhead nötig, einfacher zu testen |
| Browser-API kapseln | Custom Hook | Cleanup in useEffect zentralisiert, testbar isoliert |
Die stärkste Kombination in der Praxis ist Custom Hooks plus Compound Components: Der Hook liefert die Logik, die Compound Components strukturieren das Rendering. Ein Tabs-Hook verwaltet den aktiven Tab und die Tastaturnavigation, die Tabs-Compound-Component rendert die Struktur. Die Komponente ist sauber, der Hook ist isoliert testbar. Das ist React-Architektur auf dem aktuellen Stand der Praxis.