18 useCallback – Funktionsreferenzen stabilisieren

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.

18.1 Das Referenz-Problem verstehen

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.

18.2 Die Lösung: Referenzen stabilisieren

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.

18.3 Dependencies: Der Schlüssel zur Korrektheit

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.

18.4 Funktionale State-Updates: Dependencies reduzieren

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.

18.5 Die Symbiose mit React.memo

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

18.6 Wann useCallback sinnvoll ist

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>;
}

18.7 Komplexe Dependencies: Das Design-Signal

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

18.8 Der Unterschied zu useMemo

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

18.9 Performance messen, nicht raten

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.

18.10 Praktische Patterns

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.