Performance-Optimierung in React folgt oft einem Muster: Ein Problem
wird sichtbar, wir suchen nach der Ursache, und die Lösung liegt in
einem der Memoization-Hooks. Während useMemo berechnete
Werte cached, adressiert useCallback ein spezifisches, aber
fundamentales Problem: die ständige Neuerstellung von Funktionen bei
jedem Rendering. Dieses Problem ist subtil, seine Auswirkungen können
jedoch erheblich sein – besonders in großen Anwendungen mit tiefen
Komponentenhierarchien und optimierten Child-Komponenten.
JavaScript behandelt Funktionen als First-Class-Citizens – sie sind Objekte wie alle anderen. Und wie bei allen Objekten gilt: Zwei Funktionen sind nur dann identisch, wenn sie dieselbe Referenz haben, nicht wenn sie denselben Code enthalten.
const fn1 = () => console.log('Hello');
const fn2 = () => console.log('Hello');
fn1 === fn2; // false - verschiedene Objekte!
fn1 === fn1; // true - gleiche Referenz
In React-Komponenten werden Funktionen typischerweise im Komponenten-Body definiert. Bei jedem Rendering wird die Komponenten-Funktion neu ausgeführt, und dabei werden alle darin enthaltenen Funktionen neu erstellt – mit neuen Referenzen.
function Parent() {
const [count, setCount] = useState(0);
// Diese Funktion wird bei jedem Render NEU erstellt
const handleClick = () => {
setCount(count + 1);
};
return <Child onClick={handleClick} />;
}
Jedes Mal, wenn Parent rendert – sei es durch
count-Änderung oder aus einem anderen Grund – entsteht ein
neues handleClick-Objekt. Für die
Child-Komponente sieht es aus, als hätte sich eine Prop
geändert. Wenn Child mit React.memo optimiert
ist, wird diese Optimierung wirkungslos.
const Child = React.memo(({ onClick }: Props) => {
console.log('Child rendert');
return <button onClick={onClick}>Klick</button>;
});
// Trotz React.memo rendert Child bei jedem Parent-Rendering,
// weil onClick eine neue Referenz erhält
Dieses Verhalten ist kein Bug, sondern eine Konsequenz des Komponenten-Modells. Funktionskomponenten sind Funktionen, die bei jedem Rendering vollständig neu ausgeführt werden. Alle darin definierten Funktionen sind lokal zu diesem Durchlauf und verschwinden danach.
useCallback löst dieses Problem durch Memoization. Der
Hook nimmt eine Funktion entgegen und gibt eine memoizierte Version
zurück. Solange sich die Dependencies nicht ändern, wird bei jedem
Rendering dieselbe Funktionsreferenz zurückgegeben.
function Parent() {
const [count, setCount] = useState(0);
// Diese Funktion wird nur neu erstellt, wenn sich count ändert
const handleClick = useCallback(() => {
setCount(count + 1);
}, [count]);
return <Child onClick={handleClick} />;
}
Jetzt erhält Child bei Renderings, die nicht von
count-Änderungen ausgelöst wurden, dieselbe
Funktionsreferenz. React.memo kann seine Arbeit tun und
unnötige Re-Renderings vermeiden.
Die Syntax folgt dem bekannten Hook-Muster: Die Funktion selbst als
erster Parameter, das Dependency Array als zweiter. React vergleicht die
Dependencies zwischen Renderings. Sind sie identisch (via
Object.is), wird die gecachte Funktion zurückgegeben. Hat
sich mindestens eine Dependency geändert, wird eine neue Funktion
erstellt und gecached.
const memoizedCallback = useCallback(
() => {
// Funktionscode
},
[dep1, dep2] // Dependencies
);
Intern ist useCallback eng mit useMemo
verwandt. Tatsächlich ist useCallback(fn, deps) äquivalent
zu useMemo(() => fn, deps). useCallback ist
syntaktischer Zucker für den speziellen Fall, dass wir eine Funktion
selbst memoizieren wollen, nicht ihr Ergebnis.
Das Dependency Array ist kritisch. Alle Werte aus dem Komponenten-Scope, die in der Callback-Funktion verwendet werden, müssen gelistet sein. Andernfalls arbeitet die Funktion mit veralteten Werten – ein häufiger und schwer zu debuggender Fehler.
function SearchBox() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
// Falsch: query fehlt im Dependency Array
const search = useCallback(() => {
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(setResults);
}, []); // ❌ query wird verwendet, aber nicht gelistet
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<button onClick={search}>Suchen</button>
</div>
);
}
Hier wird search nur beim ersten Rendering erstellt
(leeres Dependency Array). Die Funktion “erinnert sich” an den initialen
query-Wert (wahrscheinlich ''). Spätere
Änderungen an query werden ignoriert – die Funktion sucht
immer nach dem leeren String.
Die korrekte Version listet query als Dependency:
const search = useCallback(() => {
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(setResults);
}, [query]); // ✓ query ist gelistet
Jetzt wird search neu erstellt, wenn sich
query ändert. Die Funktion arbeitet immer mit dem aktuellen
Wert.
ESLint mit der exhaustive-deps-Regel ist unverzichtbar.
Sie warnt automatisch vor fehlenden Dependencies und verhindert diese
Fehlerklasse.
Ein elegantes Pattern zur Reduktion von Dependencies liegt in funktionalen State-Updates. Statt den aktuellen State-Wert direkt zu verwenden, übergeben wir der Setter-Funktion eine Update-Funktion, die den vorherigen Wert als Parameter erhält.
function Counter() {
const [count, setCount] = useState(0);
// Ohne funktionales Update: count ist Dependency
const increment = useCallback(() => {
setCount(count + 1);
}, [count]); // Neue Funktion bei jeder count-Änderung
// Mit funktionalem Update: keine Dependencies nötig
const incrementStable = useCallback(() => {
setCount(prev => prev + 1);
}, []); // Funktion bleibt immer stabil
return <button onClick={incrementStable}>{count}</button>;
}
Die zweite Variante ist überlegen. incrementStable wird
einmal beim Mount erstellt und bleibt danach konstant. Sie benötigt
keinen Zugriff auf den aktuellen count-Wert, weil sie mit
dem vorherigen Wert arbeitet, den React ihr übergibt.
Dieses Pattern ist besonders wertvoll bei komplexeren Callbacks, die an viele Child-Komponenten weitergegeben werden:
function TodoList() {
const [todos, setTodos] = useState<Todo[]>([]);
// Stabil über alle Renderings
const addTodo = useCallback((text: string) => {
setTodos(prev => [...prev, { id: Date.now(), text }]);
}, []);
const toggleTodo = useCallback((id: number) => {
setTodos(prev =>
prev.map(todo =>
todo.id === id ? { ...todo, done: !todo.done } : todo
)
);
}, []);
return (
<div>
<AddTodoForm onAdd={addTodo} />
{todos.map(todo => (
<TodoItem key={todo.id} todo={todo} onToggle={toggleTodo} />
))}
</div>
);
}
Beide Callbacks bleiben über alle Renderings stabil.
AddTodoForm und TodoItem können mit
React.memo optimiert werden, und die Optimierung
funktioniert tatsächlich.
useCallback entfaltet seine volle Wirkung erst in
Kombination mit React.memo. Ohne memoizierte
Child-Komponenten bringt useCallback keinen
Performance-Gewinn – die Children rendern ohnehin bei jedem
Parent-Rendering neu.
React.memo ist ein Higher-Order-Component, der eine
Komponente in eine memoizierte Version umwandelt. Er führt einen shallow
comparison aller Props durch. Wenn alle Props identisch sind
(referenziell), wird das Re-Rendering übersprungen.
const TodoItem = React.memo(({ todo, onToggle }: Props) => {
console.log('TodoItem rendert:', todo.id);
return (
<li>
<input
type="checkbox"
checked={todo.done}
onChange={() => onToggle(todo.id)}
/>
{todo.text}
</li>
);
});
Ohne useCallback auf onToggle würde jedes
TodoItem bei jedem Parent-Rendering neu rendern, weil
onToggle eine neue Referenz erhält. Mit
useCallback rendern nur die Items, die sich tatsächlich
geändert haben.
| Szenario | Parent rendert | Child rendert? | Grund |
|---|---|---|---|
| Kein React.memo, kein useCallback | Ja | Ja | Standard-Verhalten |
| React.memo, kein useCallback | Ja | Ja | onToggle hat neue Referenz |
| Kein React.memo, useCallback | Ja | Ja | React.memo nicht aktiv |
| React.memo + useCallback | Ja | Nein* | Props sind stabil |
* Nur wenn sich die tatsächlichen Props (todo) nicht geändert haben
Nicht jede Funktion profitiert von useCallback. Der Hook
hat eigene Kosten: React muss das Dependency Array bei jedem Rendering
vergleichen und die Funktion im Speicher halten. Bei einfachen
Callbacks, die selten übergeben werden oder an nicht-memoizierte
Komponenten gehen, überwiegt dieser Overhead den Nutzen.
Gute Kandidaten für useCallback:
Event-Handler, die an memoizierte Child-Komponenten übergeben werden,
sind der Hauptanwendungsfall. Wenn die Child-Komponente mit
React.memo optimiert ist, verhindert ein stabiler Callback
unnötige Re-Renderings.
function DataTable({ data }: Props) {
const handleRowClick = useCallback((id: number) => {
console.log('Row clicked:', id);
}, []);
return (
<div>
{data.map(row => (
<MemoizedRow key={row.id} row={row} onClick={handleRowClick} />
))}
</div>
);
}
Callbacks in useEffect-Dependencies vermeiden unnötige
Effect-Ausführungen. Wenn ein Effect eine Callback-Funktion als
Dependency hat, wird er bei jeder Neuerstellung dieser Funktion
ausgeführt.
function DataFetcher({ query }: Props) {
const [data, setData] = useState(null);
const fetchData = useCallback(() => {
fetch(`/api/data?q=${query}`)
.then(res => res.json())
.then(setData);
}, [query]);
useEffect(() => {
fetchData();
}, [fetchData]); // Läuft nur neu, wenn query sich ändert
return <div>{/* ... */}</div>;
}
Custom Hooks, die Callbacks zurückgeben, sollten diese typischerweise memoizieren. Konsumenten des Hooks können nicht wissen, ob der Callback stabil ist, und müssen ihn möglicherweise in eigene Dependencies aufnehmen.
function useWindowSize() {
const [size, setSize] = useState({ width: 0, height: 0 });
const updateSize = useCallback(() => {
setSize({
width: window.innerWidth,
height: window.innerHeight
});
}, []);
useEffect(() => {
updateSize();
window.addEventListener('resize', updateSize);
return () => window.removeEventListener('resize', updateSize);
}, [updateSize]);
return size;
}
Schlechte Kandidaten für useCallback:
Einfache Event-Handler, die direkt im JSX verwendet werden und keine Props sind, profitieren selten.
function Form() {
const [value, setValue] = useState('');
// Unnötig: Callback wird nicht weitergegeben
const handleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
}, []);
// Besser: Direkt definieren
return <input value={value} onChange={e => setValue(e.target.value)} />;
}
Callbacks in Komponenten ohne React.memo-optimierte
Children gewinnen nichts. Die Children rendern ohnehin neu.
function Parent() {
const handleClick = useCallback(() => {
console.log('Clicked');
}, []);
// Child ist nicht memoiziert → useCallback bringt nichts
return <Child onClick={handleClick} />;
}
function Child({ onClick }: Props) {
return <button onClick={onClick}>Click</button>;
}
Wenn sich ein useCallback mit vielen Dependencies füllt,
ist das oft ein Hinweis auf strukturelle Probleme. Eine Funktion, die
von einem Dutzend Werten abhängt, hat wahrscheinlich zu viele
Verantwortlichkeiten.
// Warnsignal: Zu viele Dependencies
const complexCallback = useCallback(() => {
doSomething(a, b, c, d, e, f, g, h);
}, [a, b, c, d, e, f, g, h]);
Lösungsansätze:
Option 1: Komponente aufteilen
// Logik in separate Komponente extrahieren
function ComplexLogic({ a, b, c, d }: Props) {
const callback = useCallback(() => {
doSomething(a, b, c, d);
}, [a, b, c, d]);
return <Child onClick={callback} />;
}
Option 2: useReducer verwenden
const [state, dispatch] = useReducer(reducer, initialState);
// dispatch ist immer stabil, keine Dependencies nötig
const callback = useCallback(() => {
dispatch({ type: 'COMPLEX_ACTION' });
}, []);
Option 3: Refs für nicht-reaktive Werte
const configRef = useRef({ a, b, c });
useEffect(() => {
configRef.current = { a, b, c };
}, [a, b, c]);
const callback = useCallback(() => {
doSomething(configRef.current);
}, []); // Keine Dependencies außer der stabilen ref
Die Verwandtschaft zu useMemo ist offensichtlich, aber
die Anwendungsfälle unterscheiden sich klar:
// useMemo: Wert berechnen und cachen
const sortedData = useMemo(() => {
return data.sort((a, b) => a.value - b.value);
}, [data]);
// useCallback: Funktion cachen
const handleSort = useCallback(() => {
setSortBy('value');
}, []);
useMemo führt die Funktion aus und gibt das Ergebnis
zurück. useCallback gibt die Funktion selbst zurück, ohne
sie auszuführen. Man könnte useMemo für Funktionen
verwenden – useCallback ist nur bequemer:
// Diese beiden sind äquivalent:
const callback1 = useCallback(() => doSomething(), []);
const callback2 = useMemo(() => () => doSomething(), []);
// useCallback ist lesbarer für diesen Fall
Die Entscheidung folgt der Intention: - Willst du einen berechneten
Wert cachen? → useMemo - Willst du eine
Funktion stabilisieren? → useCallback
Wie bei allen Optimierungen gilt: Messe vor und nach der Änderung. React DevTools Profiler zeigt genau, welche Komponenten wie oft rendern und wie lange sie benötigen.
function ExpensiveList({ items, onItemClick }: Props) {
// Vor Optimierung: Profiler zeigt häufige Re-Renderings
const handleClick = useCallback((id: number) => {
onItemClick(id);
}, [onItemClick]);
// Nach Optimierung: Messbar weniger Renderings?
return (
<ul>
{items.map(item => (
<MemoizedItem key={item.id} item={item} onClick={handleClick} />
))}
</ul>
);
}
Die Metrik “Rendering-Anzahl reduziert” ist nur relevant, wenn die Renderings auch tatsächlich Zeit kosten. Eine Komponente, die in 0.1ms rendert, muss nicht optimiert werden, selbst wenn sie häufig rendert. Fokussiere auf Komponenten mit messbarer Render-Zeit.
Ein bewährtes Pattern für Listen mit Item-spezifischen Callbacks:
function TodoList() {
const [todos, setTodos] = useState<Todo[]>([]);
// Ein Callback für alle Items
const handleToggle = useCallback((id: number) => {
setTodos(prev =>
prev.map(todo =>
todo.id === id ? { ...todo, done: !todo.done } : todo
)
);
}, []);
return (
<ul>
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={handleToggle} // Gleiche Referenz für alle
/>
))}
</ul>
);
}
const TodoItem = React.memo(({ todo, onToggle }: Props) => (
<li onClick={() => onToggle(todo.id)}>
{todo.text}
</li>
));
Statt für jedes Item einen eigenen Callback zu erstellen, verwenden wir einen gemeinsamen, der die Item-ID als Parameter erhält. Die Child-Komponente wrapped ihn in eine anonyme Funktion, die die ID übergibt.
Für Form-Handling mit mehreren Feldern:
function Form() {
const [values, setValues] = useState({ name: '', email: '' });
const handleChange = useCallback((field: string, value: string) => {
setValues(prev => ({ ...prev, [field]: value }));
}, []);
return (
<form>
<Input value={values.name} onChange={v => handleChange('name', v)} />
<Input value={values.email} onChange={v => handleChange('email', v)} />
</form>
);
}
Ein einzelner, stabiler Callback für alle Formularfelder. Skaliert besser als individuelle Callbacks pro Feld.
useCallback ist kein Allheilmittel, sondern ein
gezieltes Werkzeug für spezifische Performance-Probleme. Richtig
eingesetzt – in Kombination mit React.memo, mit
sorgfältigen Dependencies, und basierend auf messbaren Problemen – kann
es die Performance erheblich verbessern. Falsch eingesetzt – überall und
präventiv – fügt es nur Komplexität hinzu ohne echten Nutzen. Die Kunst
liegt darin, die Grenze zu erkennen.