React-Komponenten teilen sich oft ähnliche Probleme. Mehrere Formulare brauchen Input-Validierung. Verschiedene Seiten laden Daten von derselben API. Dashboard-Widgets reagieren auf Window-Resize-Events. Vor Hooks bedeutete Code-Wiederverwendung entweder Higher-Order Components mit ihrer Wrapper-Hell oder Render Props mit verschachtelten Callbacks. Custom Hooks lösen dieses Problem mit einer einfachen Idee: Logik ist eine Funktion, die andere Hooks verwendet.
Stellen wir uns ein Szenario vor, das in fast jeder Anwendung vorkommt. Drei verschiedene Komponenten benötigen Input-Felder mit State-Management. Die naive Lösung dupliziert denselben Code dreimal.
function LoginForm() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
return (
<>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
value={password}
onChange={(e) => setPassword(e.target.value)}
type="password"
/>
</>
);
}
function RegistrationForm() {
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
// ... dieselben Input-Handler wieder und wieder
}
function ProfileEditor() {
const [displayName, setDisplayName] = useState("");
const [bio, setBio] = useState("");
// ... und wieder
}
Das Pattern ist offensichtlich: State-Variable, Setter-Funktion, Change-Handler. Diese drei Zeilen wiederholen sich in jeder Komponente, die ein Formularfeld verwaltet. Ein Custom Hook extrahiert dieses Pattern.
function useInput(initialValue: string = "") {
const [value, setValue] = useState(initialValue);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
};
return {
value,
onChange: handleChange
};
}
// Verwendung: Drastisch reduzierter Boilerplate
function LoginForm() {
const email = useInput("");
const password = useInput("");
return (
<>
<input {...email} type="email" />
<input {...password} type="password" />
</>
);
}
Der Spread-Operator ({...email}) überträgt
value und onChange direkt an das
Input-Element. Drei Zeilen pro Feld werden zu einer. Wichtiger noch: Die
Logik ist jetzt an einem Ort definiert. Änderungen – etwa zusätzliche
Validierung oder Debouncing – müssen nur einmal implementiert
werden.
Custom Hooks beginnen immer mit use. Das ist keine
kosmetische Konvention, sondern ein Signal an React und seine
Tooling-Infrastruktur. ESLint mit dem React Hooks Plugin erkennt an
diesem Präfix, dass Hook-Regeln gelten. React selbst nutzt es, um die
interne Zuordnung von Hook-Aufrufen zu verwalten.
// ✓ React erkennt dies als Hook
function useWindowSize() {
const [size, setSize] = useState({ width: 0, height: 0 });
// ...
return size;
}
// ❌ React behandelt dies als normale Funktion
function windowSize() {
const [size, setSize] = useState({ width: 0, height: 0 }); // Fehler!
// ...
}
Die Namenskonvention ermöglicht es auch den Development Tools, Hook-Aufrufe zu verfolgen. React DevTools zeigen Custom Hooks in der Komponenten-Hierarchie an und machen den State-Flow transparent.
Ein fundamentales Missverständnis: Custom Hooks teilen keine State-Instanzen zwischen Komponenten. Jeder Aufruf eines Custom Hooks erzeugt einen völlig isolierten State.
function useCounter(initial: number = 0) {
const [count, setCount] = useState(initial);
return {
count,
increment: () => setCount(c => c + 1),
decrement: () => setCount(c => c - 1)
};
}
function ComponentA() {
const counter = useCounter(0);
// counter.count ist unabhängig von ComponentB
return <div>{counter.count}</div>;
}
function ComponentB() {
const counter = useCounter(10);
// Völlig separater State, andere Initialwert
return <div>{counter.count}</div>;
}
Jede Komponente erhält ihre eigene useState-Instanz. Das
Inkrement in ComponentA ändert nichts in
ComponentB. Custom Hooks kapseln Logik, nicht
State-Instanzen. Für geteilten State zwischen Komponenten bleibt Context
oder externe State-Management-Lösungen der richtige Ansatz.
Browser-Storage ist ein Klassiker für Custom Hooks. Viele Komponenten müssen Daten persistent speichern – Theme-Präferenzen, Formular-Drafts, Sidebar-Zustände. Die Logik wiederholt sich: Lesen bei Mount, Schreiben bei Änderung.
function useLocalStorage<T>(key: string, initialValue: T) {
// Lazy Initialization: localStorage nur einmal lesen
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.warn(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
// Wrapper-Funktion: Schreibt in State UND localStorage
const setValue = (value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function
? value(storedValue)
: value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.warn(`Error setting localStorage key "${key}":`, error);
}
};
return [storedValue, setValue] as const;
}
// Verwendung: Identisch zu useState, aber persistent
function ThemeSwitcher() {
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Current: {theme}
</button>
);
}
Die as const Assertion am Ende ist ein
TypeScript-Detail, das präzise Typen erzeugt. Ohne sie würde TypeScript
den Rückgabetyp als
(T | ((value: T | ((val: T) => T)) => void))[]
inferieren – wenig hilfreich. Mit as const wird es ein
Tupel:
readonly [T, (value: T | ((val: T) => T)) => void],
genau wie useState.
Lazy Initialization ist hier kritisch. Die Callback-Variante von
useState stellt sicher, dass
localStorage.getItem() nur beim ersten Render ausgeführt
wird, nicht bei jedem Re-Render.
API-Calls sind ein weiterer Bereich, wo Custom Hooks glänzen. Eine typische Komponente, die Daten lädt, braucht: Loading-State, Error-Handling, die eigentlichen Daten. Ohne Custom Hook sieht das in jeder Komponente gleich aus.
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
async function fetchData() {
try {
setLoading(true);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const json = await response.json();
if (!cancelled) {
setData(json);
setError(null);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err : new Error(String(err)));
setData(null);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
fetchData();
return () => {
cancelled = true;
};
}, [url]);
return { data, loading, error };
}
// Verwendung: Komponente fokussiert auf Rendering, nicht auf Fetch-Logik
interface User {
id: number;
name: string;
email: string;
}
function UserProfile({ userId }: { userId: number }) {
const { data, loading, error } = useFetch<User>(
`https://api.example.com/users/${userId}`
);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!data) return null;
return (
<div>
<h1>{data.name}</h1>
<p>{data.email}</p>
</div>
);
}
Die cancelled-Flag ist essentiell. Wenn die Komponente
unmountet wird, während der Fetch noch läuft, würde ohne diese
Absicherung ein State-Update auf einer nicht mehr existierenden
Komponente versucht werden. React warnt in diesem Fall: “Can’t perform a
React state update on an unmounted component”. Die Cleanup-Funktion
setzt cancelled = true, und alle State-Updates werden
übersprungen.
| Pattern | Zweck | TypeScript-Typ |
|---|---|---|
useState<T \| null> |
Daten noch nicht geladen | Expliziter Null-Zustand |
as const bei Tupel-Returns |
Präzise Array-Typen | Verhindert Union-Type-Arrays |
Generischer Hook <T> |
Flexibilität für verschiedene APIs | Type-Safety beim Aufruf |
| Cleanup-Flag | Race Conditions vermeiden | Boolean-Flag im Closure |
Browser-Events außerhalb von React zu handhaben, war immer umständlich. Ein Custom Hook macht reaktive Browser-APIs einfach.
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight
});
useEffect(() => {
function handleResize() {
setSize({
width: window.innerWidth,
height: window.innerHeight
});
}
window.addEventListener('resize', handleResize);
// Cleanup: Event-Listener entfernen
return () => window.removeEventListener('resize', handleResize);
}, []);
return size;
}
// Responsive Komponente ohne Media Queries
function ResponsiveGrid() {
const { width } = useWindowSize();
const columns = width < 768 ? 1 : width < 1024 ? 2 : 3;
return (
<div style={{
display: 'grid',
gridTemplateColumns: `repeat(${columns}, 1fr)`
}}>
{/* Grid Items */}
</div>
);
}
Wichtig: Die leere Dependency-Array [] bei
useEffect stellt sicher, dass Event-Listener nur einmal
registriert werden. Ohne diese würde bei jedem Re-Render ein neuer
Listener hinzugefügt – und die alten würden bleiben. Memory Leak
garantiert.
Custom Hooks können andere Custom Hooks verwenden. Diese Komposition ermöglicht modulare, wiederverwendbare Bausteine.
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(handler);
}, [value, delay]);
return debouncedValue;
}
function useSearchQuery(initialQuery: string = "") {
const [query, setQuery] = useState(initialQuery);
const debouncedQuery = useDebounce(query, 500);
return {
query,
setQuery,
debouncedQuery // Nur dieser Wert triggert API-Calls
};
}
// Verwendung: Suchfeld mit automatischer Verzögerung
function SearchBox() {
const { query, setQuery, debouncedQuery } = useSearchQuery();
const { data, loading } = useFetch<SearchResult[]>(
`https://api.example.com/search?q=${debouncedQuery}`
);
return (
<>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
{loading && <span>Searching...</span>}
{data && <ResultsList results={data} />}
</>
);
}
useSearchQuery kombiniert lokalen State mit Debouncing.
useDebounce ist wiederverwendbar für beliebige Werte. Die
Komponente SearchBox nutzt beide, ohne die
Implementation-Details zu kennen. Jeder Hook hat eine klare
Verantwortung, zusammen bilden sie ein leistungsfähiges System.
Custom Hooks profitieren massiv von TypeScript. Generische Typen machen Hooks flexibel, während Type Inference die Verwendung einfach hält.
// Generischer Hook mit Type Constraints
function useArray<T>(initialArray: T[] = []) {
const [array, setArray] = useState<T[]>(initialArray);
const push = (element: T) => {
setArray(current => [...current, element]);
};
const remove = (index: number) => {
setArray(current => current.filter((_, i) => i !== index));
};
const clear = () => {
setArray([]);
};
return {
array,
set: setArray,
push,
remove,
clear
};
}
// Type Inference funktioniert automatisch
function TodoList() {
const todos = useArray<string>([]); // Explizit
const numbers = useArray([1, 2, 3]); // Inferiert: number[]
todos.push("New todo"); // ✓ Type-safe
todos.push(42); // ❌ Type error
}
Für komplexere Rückgabewerte sollte ein explizites Interface den Return-Type definieren. Das macht die API selbstdokumentierend.
interface UseFetchResult<T> {
data: T | null;
loading: boolean;
error: Error | null;
refetch: () => void;
}
function useFetch<T>(url: string): UseFetchResult<T> {
// Implementation...
const refetch = () => {
// Re-trigger fetch
};
return { data, loading, error, refetch };
}
IDE-Autocomplete zeigt dann alle verfügbaren Felder und ihre Typen. Nutzer des Hooks müssen nicht die Implementation lesen, um zu verstehen, was zurückgegeben wird.
Ein häufiger Fehler: Funktionen, die bei jedem Render neu erstellt werden, aus dem Hook zurückgeben. Das führt zu unnötigen Re-Renders in Child-Komponenten.
// ❌ Problematisch: Neue Funktion-Referenz bei jedem Render
function useCounter() {
const [count, setCount] = useState(0);
return {
count,
increment: () => setCount(c => c + 1), // Neue Referenz!
decrement: () => setCount(c => c - 1) // Neue Referenz!
};
}
// ✓ Richtig: Stabile Referenzen mit useCallback
function useCounter() {
const [count, setCount] = useState(0);
const increment = useCallback(() => {
setCount(c => c + 1);
}, []);
const decrement = useCallback(() => {
setCount(c => c - 1);
}, []);
return { count, increment, decrement };
}
Wenn eine Child-Komponente increment in ihre
Dependencies aufnimmt (z.B. in useEffect), würde ohne
useCallback jedes Parent-Re-Render den Effect neu triggern.
Mit useCallback bleibt die Referenz stabil.
Dasselbe gilt für Objekte. Der Hook sollte kein neues Objekt bei jedem Render zurückgeben, es sei denn, sich tatsächlich etwas geändert hat.
// ❌ Neues Objekt bei jedem Render
function useUser(id: number) {
const [user, setUser] = useState<User | null>(null);
return { // Immer neue Referenz!
user,
isLoading: user === null
};
}
// ✓ Nur neue Referenz wenn user sich ändert
function useUser(id: number) {
const [user, setUser] = useState<User | null>(null);
return useMemo(() => ({
user,
isLoading: user === null
}), [user]);
}
Bedingte Hook-Aufrufe verletzen die Hook-Regeln, auch in Custom Hooks.
// ❌ Falsch: Hook in Bedingung
function useConditionalData(shouldFetch: boolean) {
if (shouldFetch) {
const data = useFetch('/api/data'); // Regel-Verstoß!
return data;
}
return null;
}
// ✓ Richtig: Bedingung innerhalb des Hooks
function useConditionalData(shouldFetch: boolean) {
const [data, setData] = useState(null);
useEffect(() => {
if (!shouldFetch) return;
// Fetch-Logik hier
}, [shouldFetch]);
return data;
}
Vergessene Cleanups führen zu Memory Leaks. Jeder registrierte Listener, jeder Timer, jedes Subscription muss aufgeräumt werden.
// ❌ Memory Leak: Listener wird nie entfernt
function useMousePosition() {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
function handleMove(e: MouseEvent) {
setPosition({ x: e.clientX, y: e.clientY });
}
window.addEventListener('mousemove', handleMove);
// Cleanup fehlt!
}, []);
return position;
}
// ✓ Korrekt: Cleanup-Funktion
function useMousePosition() {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
function handleMove(e: MouseEvent) {
setPosition({ x: e.clientX, y: e.clientY });
}
window.addEventListener('mousemove', handleMove);
return () => {
window.removeEventListener('mousemove', handleMove);
};
}, []);
return position;
}
Über-Abstraktion ist ebenfalls ein Antipattern. Nicht jede Logik braucht einen Custom Hook. Wenn Code nur in einer Komponente verwendet wird und einfach ist, gehört er dort hinein.
// ❌ Unnötiger Custom Hook
function useButtonText(isLoading: boolean) {
return isLoading ? "Loading..." : "Submit";
}
// ✓ Direkt in der Komponente
function Form() {
const [isLoading, setIsLoading] = useState(false);
const buttonText = isLoading ? "Loading..." : "Submit";
return <button>{buttonText}</button>;
}
Custom Hooks sollten echte Wiederverwendung ermöglichen oder komplexe Logik kapseln. Ein Hook, der nur eine Zeile Logic enthält, ist meistens Overhead ohne Mehrwert.