Custom Hook in React: Logik richtig wiederverwenden
1. Überblick
Wenn mehrere Komponenten in deiner React-Anwendung die gleiche Logik benötigen — API-Aufrufe, localStorage lesen, Debouncing von Input — verfällst du leicht in Copy-Paste-Fallen mit State und useEffect. Dieser Artikel zeigt dir, wie du diese Logik in einen Custom Hook packst: eine Funktion, die mit use beginnt und bereits existierende Hooks (useState, useEffect, useRef…) nutzt, um sie wiederzuverwenden. Nach dem Lesen kannst du useFetch, useLocalStorage, useDebounce schreiben und sie mit renderHook testen.
2. Problem: Logik, die sich wiederholt
Angenommen, du hast zwei Komponenten, die beide eine API aufrufen und loading/error verwalten müssen:
function CustomerList() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/customers')
.then((res) => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, []);
// ...render
}
function OrderList() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/orders')
.then((res) => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, []);
// ...render
}
CustomerList und OrderList enthalten fast die gleiche Logik, unterscheiden sich nur in der URL. Wenn du später Retry oder Request-Abbruch hinzufügen möchtest, musst du überall ändern — das ist ein klares Zeichen, dass du einen Custom Hook brauchst.
3. Was ist ein Custom Hook?
Ein Custom Hook ist eine normale JavaScript-Funktion, deren Name mit use beginnt, die andere Hooks von React aufruft. Er gibt kein JSX zurück — er gibt State und Funktionen zurück, ähnlich wie ein kleiner „Store” gebunden an die Komponente, die ihn aufruft.
Hinweis: Jedes Mal wenn eine Komponente einen Custom Hook aufruft, erstellt React eine separate State-Instanz. Zwei Komponenten, die
useFetchverwenden, teilen sich den State nicht — sie teilen sich nur die Logik.
4. Den ersten Custom Hook schreiben: useFetch
// useFetch.js
import { useState, useEffect } from 'react';
export function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
fetch(url)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((json) => {
if (!cancelled) setData(json);
})
.catch((err) => {
if (!cancelled) setError(err);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [url]);
return { data, loading, error };
}
Erklärung:
cancelledverhindert Race Conditions: Falls sichurländert bevor die alte Request fertig ist, wird das alte Ergebnis nicht mehr in den State geschrieben.- Die Cleanup-Funktion (
return () => { cancelled = true }) wird ausgeführt, wenn die Komponente unmountet oder sichurländert — genau im Lebenszyklus vonuseEffect. - Der Hook gibt ein Object
{ data, loading, error }zurück, damit es leicht zu destructurieren ist.
Die Komponente wird jetzt kurz:
function CustomerList() {
const { data, loading, error } = useFetch('/api/customers');
if (loading) return <p>Wird geladen...</p>;
if (error) return <p>Fehler: {error.message}</p>;
return <ul>{data.map((c) => <li key={c.id}>{c.name}</li>)}</ul>;
}
function OrderList() {
const { data, loading, error } = useFetch('/api/orders');
// Render-Logik ähnlich, keine duplizierte Fetch-Logik mehr
}
5. Zweites Beispiel: useLocalStorage
Die Logik zum Synchronisieren von State mit localStorage wird auch oft kopiert:
// useLocalStorage.js
import { useState, useEffect } from 'react';
export function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
const stored = window.localStorage.getItem(key);
return stored !== null ? JSON.parse(stored) : initialValue;
});
useEffect(() => {
window.localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
Erklärung:
useState(() => ...)nutzt einen Lazy Initializer — die Funktion wird nur einmal beim Mount ausgeführt, localStorage wird nicht bei jedem Render gelesen.- Der Hook gibt ein Tuple
[value, setValue]zurück, genau wieuseState, damit es dem Nutzer sofort vertraut ist.
In der Komponente verwendet:
function ThemeToggle() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Aktuelles Theme: {theme}
</button>
);
}
5.1. Wenn der State vom vorherigen Wert abhängt
Wenn du basierend auf dem alten Wert aktualisieren möchtest (z. B. Counter), nutze Functional Updates um Stale Closures zu vermeiden:
setValue((prev) => prev + 1); // sicherer als setValue(value + 1)
6. Drittes Beispiel: useDebounce
Debouncing von Such-Input ist ein sehr häufiges Problem:
// useDebounce.js
import { useState, useEffect } from 'react';
export function useDebounce(value, delayMs = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}
Erklärung:
- Jedes Mal wenn
valuesich ändert, wird ein neuersetTimeouterstellt; der altesetTimeoutwird in der Cleanup mitclearTimeoutgelöscht. - Nur wenn der Nutzer für
delayMsaufhört zu tippen, wirddebouncedaktualisiert.
function SearchBox() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 500);
useEffect(() => {
if (debouncedQuery) {
fetch(`/api/search?q=${debouncedQuery}`);
}
}, [debouncedQuery]);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
7. Namenskonventionen und Rules of Hooks
Custom Hooks müssen die Rules of Hooks wie Built-in-Hooks befolgen:
- Name muss mit
usebeginnen — das ESLint Plugineslint-plugin-react-hooksverlässt sich auf dieses Präfix, um zu überprüfen, ob die Regel eingehalten wird. Ohneusewird es nicht als Hook erkannt. - Nur Top-Level aufrufen, nicht innerhalb von
if,foroder Callbacks. - Nur in Function Components oder anderen Custom Hooks aufrufen, nicht in normalen Funktionen.
npm install eslint-plugin-react-hooks --save-dev
{
"plugins": ["react-hooks"],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}
Aktiviere exhaustive-deps, um nicht zu vergessen, Dependencies in useEffect des Custom Hooks anzugeben — die häufigste Ursache für „Stale Data”-Bugs.
8. Custom Hook testen
Da Custom Hooks kein JSX zurückgeben, kannst du sie nicht wie Komponenten mit render testen. Die Bibliothek @testing-library/react bietet renderHook, um den Hook in einer Test-Umgebung aufzurufen.
<!-- Nicht anwendbar für React-Projekte; Abhängigkeit wird über npm installiert, siehe unten -->
npm install --save-dev vitest @testing-library/react
// useDebounce.test.jsx
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { useDebounce } from './useDebounce';
describe('useDebounce', () => {
it('aktualisiert den Wert erst nach der Verzögerung', () => {
vi.useFakeTimers();
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: 'a', delay: 300 } }
);
expect(result.current).toBe('a');
rerender({ value: 'ab', delay: 300 });
expect(result.current).toBe('a'); // noch nicht geändert
act(() => {
vi.advanceTimersByTime(300);
});
expect(result.current).toBe('ab'); // debounce fertig
vi.useRealTimers();
});
});
Erklärung:
renderHooknimmt eine Funktion entgegen, die den Hook aufruft, und gibtresult.currentzurück — der Rückgabewert des Hooks beim aktuellen Render.rerendersimuliert eine Komponenten-Re-Render mit neuen Props (wie wenn sichvalueändert, weil der Nutzer tippt).vi.useFakeTimers()+act(() => vi.advanceTimersByTime(...))lässt dich die Zeit kontrollieren, ohne darauf zu warten — Tests sind schnell und stabil.
✓ useDebounce > aktualisiert den Wert erst nach der Verzögerung
9. Häufige Fehler und wann man Custom Hooks nicht verwenden sollte
Häufige Fehler:
- Cleanup in
useEffectvergessen: BeiuseFetchkann eine alte Request später zurückkommen und neue Daten überschreiben — zeigt die falschen Daten an, falls du diecancelled-Flag nicht hast. - Fehlende Dependencies: Falls
useEffectim Hook externe Variablen nutzt, aber diese nicht im Dependency Array deklariert sind, wird der Hook einen veralteten Wert verwenden (Stale Closure). - Unnötiges neues Object bei jedem Render zurückgeben: Wenn der Hook
{ data, loading, error }als Object Literal bei jedem Render zurückgibt, könnte eine Komponente mitReact.memounnötig re-rendern, weil die Referenz sich ändert. Du kannstuseMemonutzen, wenn du das Problem wirklich gemessen hast.
Wann du KEINEN Custom Hook schreiben solltest:
- Logik, die nur an einer Stelle verwendet wird und keine Chance auf Wiederverwendung hat — Extraktion macht Code nur schwerer zu folgen (musst in andere Datei springen, um zu verstehen).
- Logik, die reine Berechnung ist (Pure Function), hat nichts mit State/Effect zu tun — schreib eine normale JS-Funktion, keinen Hook.
- Wenn du State (nicht nur Logik) zwischen mehreren Komponenten an verschiedenen Stellen im Tree teilen möchtest — Custom Hooks können das nicht, du brauchst Context oder State Management Library (Zustand, Redux…).
Hinweis: Custom Hooks lösen das Problem duplizierter Logik, nicht das Problem Teilen von State zwischen Komponenten. Verwechsle diese beiden Aufgaben nicht.
10. Fazit
Custom Hooks sind ein Werkzeug zum Extrahieren von stateful Logik aus Komponenten, macht Code kurz, einfach zu testen, leicht wiederzuverwenden. Du musst nur beachten: Name beginnt mit use, halte dich an die Rules of Hooks, und teste mit renderHook statt render. Mit useFetch, useLocalStorage, useDebounce oben hast du ein Grundgerüst um weitere Hooks wie useMediaQuery, useForm im Geiste „schreib einmal, nutze überall” zu schreiben.