React-Hooks-Patterns: mehr als useState und useEffect
Custom Hooks, Kompositionsmuster und ihre Regeln: praktische React-Patterns für komplexen State und Side Effects ohne Klassenkomponenten.

Hooks haben Klassenkomponenten als Standardmethode zur Verwaltung von State und Side Effects in React abgelöst. Doch jenseits von useState und useEffect gibt es eine ganze Reihe von Patterns, um Logik in wiederverwendbare Custom Hooks auszulagern, komplexe State-Übergänge sauber zu verwalten und die typischen Fallstricke zu vermeiden, die zu veralteten Closures (Stale Closures) und endlosen Re-Renders führen.
Custom Hooks: wiederverwendbare Logik extrahieren
Ein Custom Hook ist eine Funktion, die andere Hooks verwendet. Er kapselt zustandsbehaftete Logik, die sich über mehrere Komponenten hinweg teilen lässt.
// ❌ Duplicated fetch logic in every component
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, [userId]);
// Same pattern copied into OrderList, ProductPage, etc.
}
// ✅ Extracted into a reusable hook
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(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(url, { signal: controller.signal })
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then(setData)
.catch(err => {
if (err.name !== "AbortError") setError(err);
})
.finally(() => setLoading(false));
return () => controller.abort();
}, [url]);
return { data, loading, error };
}
// Clean usage in any component
function UserProfile({ userId }: { userId: string }) {
const { data: user, loading, error } = useFetch<User>(`/api/users/${userId}`);
if (loading) return <Skeleton />;
if (error) return <ErrorMessage error={error} />;
return <ProfileCard user={user!} />;
}useReducer für komplexen State
Wenn State-Übergänge vom aktuellen State abhängen oder mehrere zusammenhängende Werte betreffen, ist useReducer übersichtlicher als mehrere useState-Aufrufe.
// ❌ Multiple useState calls with coupled updates
function OrderForm() {
const [items, setItems] = useState<OrderItem[]>([]);
const [total, setTotal] = useState(0);
const [discount, setDiscount] = useState(0);
const [status, setStatus] = useState<"idle" | "submitting" | "error">("idle");
function addItem(item: OrderItem) {
setItems([...items, item]);
setTotal(total + item.price);
// Easy to forget updating total when removing items
}
}
// ✅ useReducer keeps related state transitions consistent
type OrderAction =
| { type: "ADD_ITEM"; item: OrderItem }
| { type: "REMOVE_ITEM"; itemId: string }
| { type: "APPLY_DISCOUNT"; percentage: number }
| { type: "SUBMIT" }
| { type: "SUBMIT_ERROR"; error: string };
interface OrderState {
items: OrderItem[];
total: number;
discount: number;
status: "idle" | "submitting" | "error";
error: string | null;
}
function orderReducer(state: OrderState, action: OrderAction): OrderState {
switch (action.type) {
case "ADD_ITEM":
return {
...state,
items: [...state.items, action.item],
total: state.items.reduce((sum, i) => sum + i.price, 0) + action.item.price,
};
case "REMOVE_ITEM":
const filtered = state.items.filter(i => i.id !== action.itemId);
return {
...state,
items: filtered,
total: filtered.reduce((sum, i) => sum + i.price, 0),
};
case "APPLY_DISCOUNT":
return { ...state, discount: action.percentage };
case "SUBMIT":
return { ...state, status: "submitting", error: null };
case "SUBMIT_ERROR":
return { ...state, status: "error", error: action.error };
default:
return state;
}
}useCallback und useMemo: unnötige Arbeit vermeiden
Memoisiere Callbacks, um unnötige Re-Renders von Kindkomponenten und wiederholte teure Berechnungen bei jedem Rendering zu vermeiden.
// ❌ New function reference on every render → child re-renders
function ParentComponent({ items }: { items: Item[] }) {
const handleSelect = (id: string) => {
console.log("Selected:", id);
};
// filteredItems recalculated on every render, even if items hasn't changed
const filteredItems = items.filter(i => i.active);
return <ItemList items={filteredItems} onSelect={handleSelect} />;
}
// ✅ Stable references with useCallback and useMemo
function ParentComponent({ items }: { items: Item[] }) {
const handleSelect = useCallback((id: string) => {
console.log("Selected:", id);
}, []);
const filteredItems = useMemo(
() => items.filter(i => i.active),
[items]
);
return <ItemList items={filteredItems} onSelect={handleSelect} />;
}
// Only memoize the child if it's expensive to render
const ItemList = React.memo(function ItemList({
items,
onSelect,
}: {
items: Item[];
onSelect: (id: string) => void;
}) {
return (
<ul>
{items.map(item => (
<li key={item.id} onClick={() => onSelect(item.id)}>
{item.name}
</li>
))}
</ul>
);
});useRef für dauerhafte Werte ohne Re-Renders
Refs speichern veränderliche Werte, die über mehrere Renderings hinweg bestehen bleiben, ohne bei einer Änderung ein erneutes Rendering auszulösen.
function useInterval(callback: () => void, delayMs: number) {
const savedCallback = useRef(callback);
// Update the ref when callback changes — no re-render needed
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
const id = setInterval(() => savedCallback.current(), delayMs);
return () => clearInterval(id);
}, [delayMs]);
}
// Usage: callback can change without resetting the interval
function PollComponent() {
const [count, setCount] = useState(0);
useInterval(() => {
setCount(c => c + 1); // Always uses latest count
}, 1000);
return <div>Count: {count}</div>;
}Veraltete Closures vermeiden
Der häufigste Fehler bei Hooks: Ein Callback erfasst einen alten Wert aus einem vorherigen Rendering.
// ❌ Stale closure — count is always 0 inside the timeout
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setTimeout(() => {
console.log(`Count is: ${count}`); // Always logs 0
}, 5000);
return () => clearTimeout(id);
}, []); // Empty deps → closure captures initial count (0)
return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
}
// ✅ Use a ref to read the latest value
function Timer() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
countRef.current = count;
useEffect(() => {
const id = setTimeout(() => {
console.log(`Count is: ${countRef.current}`); // Reads current value
}, 5000);
return () => clearTimeout(id);
}, []);
return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
}Die wichtigsten Punkte
- Custom Hooks extrahieren wiederverwendbare, zustandsbehaftete Logik — Verhalten wird zwischen Komponenten geteilt, ganz ohne Render Props oder HOCs
- useReducer verwaltet komplexe State-Übergänge — zusammenhängende State-Updates bleiben in einem einzigen Reducer konsistent
- Memoisiere gezielt — useCallback und useMemo verhindern unnötige Arbeit, aber nur, wenn die Kindkomponente tatsächlich React.memo verwendet
- useRef für Werte, die kein Re-Rendering auslösen sollen — Timer-IDs, DOM-Referenzen und Referenzen auf den aktuellsten Callback
- Achte auf veraltete Closures — Effects mit leeren Abhängigkeits-Arrays erfassen die Anfangswerte; verwende Refs, um aktuelle Werte auszulesen
- Halte dich an die Regeln der Hooks — rufe Hooks immer auf der obersten Ebene auf, niemals innerhalb von Bedingungen oder Schleifen


