Wiederverwendbare React-Hooks bauen: Patterns und Anti-Patterns
Wie man wirklich wiederverwendbare Custom Hooks entwirft: Kompositionsmuster, Teststrategien, State-Kapselung und die häufigsten Anti-Patterns.

Custom Hooks sind Reacts zentrale Abstraktion, um zustandsbehaftete Logik zwischen Komponenten zu teilen. Aber die meisten Custom Hooks sind nicht wirklich wiederverwendbar – sie sind nur useEffect-Wrapper, die Komponentenlogik in eine separate Datei verschieben, ohne die API zu verbessern. Ein gut gestalteter Hook kapselt Komplexität, stellt eine minimale Schnittstelle bereit und lässt sich natürlich mit anderen Hooks komponieren.
Der Unterschied zwischen einem wiederverwendbaren Hook und einer einmaligen Extraktion ist derselbe wie zwischen einer Bibliotheksfunktion und copy-pastetem Code. Das eine ist für seine Nutzer gedacht. Das andere liegt nur zufällig in einer separaten Datei.
Der Single-Responsibility-Hook
Jeder Hook sollte genau eine Aufgabe verwalten. Wenn dein Hook Fetching, Caching und Polling macht, sind das drei Hooks im Trenchcoat.
// ❌ Kitchen-sink hook — does too much
function useUserDashboard(userId: string) {
const [user, setUser] = useState<User | null>(null);
const [posts, setPosts] = useState<Post[]>([]);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
Promise.all([
fetchUser(userId),
fetchPosts(userId),
fetchNotifications(userId),
]).then(([u, p, n]) => {
setUser(u);
setPosts(p);
setNotifications(n);
setLoading(false);
});
}, [userId]);
return { user, posts, notifications, loading };
}
// Can't reuse the fetch logic independently
// Can't use posts fetching without notifications// ✅ Focused hooks that compose
function useFetch<T>(url: string): {
data: T | null;
loading: boolean;
error: Error | null;
} {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(url)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(result => {
if (!cancelled) {
setData(result);
setLoading(false);
}
})
.catch(err => {
if (!cancelled) {
setError(err);
setLoading(false);
}
});
return () => { cancelled = true; };
}, [url]);
return { data, loading, error };
}
// Compose in the component
function UserDashboard({ userId }: { userId: string }) {
const user = useFetch<User>(`/api/users/${userId}`);
const posts = useFetch<Post[]>(`/api/users/${userId}/posts`);
const notifications = useFetch<Notification[]>(`/api/notifications`);
if (user.loading) return <Skeleton />;
// Each data source is independent
}Stabile Referenzen mit useCallback und useMemo
Hooks, die Funktionen oder Objekte zurückgeben, erzeugen bei jedem Render neue Referenzen. Das bricht React.memo und verursacht unnötige Re-Renders in Kindkomponenten.
// ❌ New object reference on every render
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
});
// This function is recreated every render
const setStoredValue = (newValue: T) => {
setValue(newValue);
localStorage.setItem(key, JSON.stringify(newValue));
};
// New object every render — consumers always re-render
return { value, setValue: setStoredValue };
}// ✅ Stable references with useCallback
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
});
const setStoredValue = useCallback(
(newValue: T | ((prev: T) => T)) => {
setValue(prev => {
const resolved = typeof newValue === 'function'
? (newValue as (prev: T) => T)(prev)
: newValue;
localStorage.setItem(key, JSON.stringify(resolved));
return resolved;
});
},
[key]
);
return [value, setStoredValue] as const;
}
// Usage — tuple return like useState
const [theme, setTheme] = useLocalStorage('theme', 'light');
setTheme('dark');
setTheme(prev => prev === 'light' ? 'dark' : 'light');Das Tupel als Rückgabewert ([value, setter]) spiegelt useState wider und macht die API vertraut. Das useCallback sorgt dafür, dass die Setter-Funktion eine stabile Referenz hat.
Komplexe State Machines kapseln
Hooks zeigen ihre Stärke, wenn sie die Komplexität einer State Machine hinter einer einfachen Schnittstelle verstecken.
// ❌ Exposing state machine internals to the component
function FileUploader() {
const [file, setFile] = useState<File | null>(null);
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState<'idle' | 'uploading' | 'done' | 'error'>('idle');
const [error, setError] = useState<string | null>(null);
const upload = async () => {
if (!file) return;
setStatus('uploading');
setProgress(0);
try {
await uploadFile(file, (p) => setProgress(p));
setStatus('done');
} catch (err) {
setStatus('error');
setError(err.message);
}
};
const reset = () => {
setFile(null);
setProgress(0);
setStatus('idle');
setError(null);
};
// Component manages all state transitions
}// ✅ Hook encapsulates the state machine
interface UploadState {
status: 'idle' | 'selecting' | 'uploading' | 'done' | 'error';
file: File | null;
progress: number;
error: string | null;
url: string | null;
}
function useFileUpload(options?: { maxSizeMB?: number; accept?: string[] }) {
const [state, setState] = useState<UploadState>({
status: 'idle',
file: null,
progress: 0,
error: null,
url: null,
});
const selectFile = useCallback((file: File) => {
const maxSize = (options?.maxSizeMB ?? 10) * 1024 * 1024;
if (file.size > maxSize) {
setState(s => ({
...s,
status: 'error',
error: `File too large (max ${options?.maxSizeMB ?? 10}MB)`,
}));
return;
}
if (options?.accept && !options.accept.includes(file.type)) {
setState(s => ({
...s,
status: 'error',
error: `Invalid file type. Accepted: ${options.accept!.join(', ')}`,
}));
return;
}
setState({ status: 'selecting', file, progress: 0, error: null, url: null });
}, [options?.maxSizeMB, options?.accept]);
const upload = useCallback(async () => {
if (!state.file || state.status !== 'selecting') return;
setState(s => ({ ...s, status: 'uploading', progress: 0 }));
try {
const url = await uploadFile(state.file, (progress) => {
setState(s => ({ ...s, progress }));
});
setState(s => ({ ...s, status: 'done', url, progress: 100 }));
} catch (err) {
setState(s => ({
...s,
status: 'error',
error: err instanceof Error ? err.message : 'Upload failed',
}));
}
}, [state.file, state.status]);
const reset = useCallback(() => {
setState({
status: 'idle',
file: null,
progress: 0,
error: null,
url: null,
});
}, []);
return {
...state,
selectFile,
upload,
reset,
canUpload: state.status === 'selecting',
} as const;
}
// Clean component usage
function FileUploader() {
const uploader = useFileUpload({ maxSizeMB: 5, accept: ['image/png', 'image/jpeg'] });
return (
<div>
<input type="file" onChange={e => {
if (e.target.files?.[0]) uploader.selectFile(e.target.files[0]);
}} />
{uploader.canUpload && <button onClick={uploader.upload}>Upload</button>}
{uploader.status === 'uploading' && <Progress value={uploader.progress} />}
{uploader.status === 'done' && <p>Uploaded: {uploader.url}</p>}
{uploader.error && <p className="error">{uploader.error}</p>}
</div>
);
}Die Komponente verwaltet keine Zustandsübergänge. Sie ruft Aktionen auf (selectFile, upload, reset) und liest abgeleiteten State (canUpload, status). Der Hook besitzt die gesamte Validierungs- und Übergangslogik.
Custom Hooks testen
Teste Hooks über ihre öffentliche API – die Werte und Funktionen, die sie zurückgeben – nicht über ihren internen State.
import { renderHook, act } from '@testing-library/react';
describe('useLocalStorage', () => {
beforeEach(() => localStorage.clear());
it('returns initial value when storage is empty', () => {
const { result } = renderHook(() => useLocalStorage('key', 'default'));
expect(result.current[0]).toBe('default');
});
it('persists value to localStorage', () => {
const { result } = renderHook(() => useLocalStorage('theme', 'light'));
act(() => {
result.current[1]('dark');
});
expect(result.current[0]).toBe('dark');
expect(localStorage.getItem('theme')).toBe('"dark"');
});
it('supports updater function', () => {
const { result } = renderHook(() => useLocalStorage('count', 0));
act(() => {
result.current[1](prev => prev + 1);
});
expect(result.current[0]).toBe(1);
});
it('reads existing value from storage', () => {
localStorage.setItem('theme', '"dark"');
const { result } = renderHook(() => useLocalStorage('theme', 'light'));
expect(result.current[0]).toBe('dark');
});
});renderHook rendert den Hook in einer isolierten Testkomponente. act umschließt State-Updates. Teste die zurückgegebenen Werte und Verhaltensweisen, nicht Implementierungsdetails.
Anti-Patterns, die man vermeiden sollte
// ❌ Anti-pattern 1: Hook that just wraps a single useEffect
function useDocumentTitle(title: string) {
useEffect(() => {
document.title = title;
}, [title]);
}
// This is so thin it adds indirection without abstraction value.
// Just write the useEffect in the component.
// ❌ Anti-pattern 2: Hook that takes too many config options
function useFetch(url, {
method, headers, body, cache, timeout, retries,
retryDelay, transform, onSuccess, onError,
dedupe, polling, pollingInterval, ...rest
}) { /* ... */ }
// This is a fetch wrapper, not a hook. Use a library.
// ❌ Anti-pattern 3: Hook that returns too many values
function useForm() {
return {
values, errors, touched, dirty, isValid, isSubmitting,
handleChange, handleBlur, handleSubmit, setFieldValue,
setFieldError, setFieldTouched, resetForm, validateField,
validateForm, registerField, unregisterField,
};
}
// Return an object with methods on it instead of 15 loose valuesDie wichtigsten Erkenntnisse
- Ein Hook, eine Aufgabe – wenn er Fetching, Caching und Polling macht, teile ihn in drei Hooks auf
- Stabilisiere zurückgegebene Referenzen mit
useCallbackunduseMemo– instabile Referenzen brechenReact.memo - Kapsle State Machines – stelle Aktionen und abgeleiteten State bereit, keine rohen setState-Funktionen
- Gib Tupel für einfache Hooks zurück (
[value, setter]) und Objekte für komplexe - Teste über die öffentliche API – prüfe zurückgegebene Werte, nicht den internen State
- Vermeide triviale Wrapper – wenn der Hook nur ein einziges
useEffectist, erzeugt er Indirektion ohne Mehrwert


