Debouncing bedeutet: ein Wert wird erst nach einer Pause übernommen. Während der User tippt, ändert sich der Live-Wert pro Tastendruck — der debounced Wert aktualisiert sich aber erst, wenn der User N Millisekunden lang nichts mehr getippt hat. Praktischer Use-Case Nummer eins: ein Such-Feld, das pro Tastendruck eine API-Anfrage starten würde — mit Debounce wird der Request erst gestartet, wenn der User seine Eingabe „abgeschlossen" hat. Aber auch: Window-Resize-Handler, Form-Validation-Trigger, beliebige hochfrequente Inputs. Dieser Artikel baut useDebounce Schritt für Schritt auf, zeigt zwei verschiedene Implementierungs-Varianten (Value-Debounce und Function-Debounce) und vergleicht beide mit useDeferredValue und Debounce-Libraries wie lodash.

Was Debouncing wirklich tut

Stell dir ein Such-Feld vor. Der User tippt „react hooks" — 12 Zeichen, also 12 onChange-Events in schneller Folge. Ohne Debouncing würde jede Eingabe einen API-Request starten:

JavaScript OhneDebounce
// 12 Tastendrücke = 12 Requests
"r"GET /search?q=r
"re"GET /search?q=re
"rea"GET /search?q=rea
"reac"GET /search?q=reac
"react"GET /search?q=react
"react "GET /search?q=react%20
"react h"GET /search?q=react%20h
"react ho"GET /search?q=react%20ho
// … usw.

Das ist verschwenderisch (11 Requests, deren Antworten der User nie sieht), kann Race-Conditions erzeugen (Antworten kommen in falscher Reihenfolge) und belastet Server unnötig.

Mit Debouncing (300 ms): der API-Request startet erst, wenn der User 300 ms lang nichts mehr getippt hat. Realistisch tippt jemand eine Suchphrase in 1-2 Sekunden ohne 300-ms-Pausen — am Ende fließt genau ein Request für „react hooks".

Das ist die Grund-Idee. Jetzt schauen wir die zwei verbreiteten Implementierungs-Varianten an.

Variante 1: useDebounce(value, delay) — den Wert verzögern

Diese Variante nimmt einen Wert und liefert eine „nachhinkende" Kopie, die erst nach delay Millisekunden ohne weitere Änderungen aktualisiert wird.

TypeScript useDebounce.js
import { useState, useEffect } from 'react';

export function useDebounce(value, delay = 300) {
    const [debouncedValue, setDebouncedValue] = useState(value);

    useEffect(() => {
        // Timer starten, der den Wert nach `delay` übernimmt
        const timerId = setTimeout(() => {
            setDebouncedValue(value);
        }, delay);

        // Cleanup: bei Wert-Wechsel oder Unmount alten Timer löschen
        return () => clearTimeout(timerId);
    }, [value, delay]);

    return debouncedValue;
}

Wie das funktioniert — Schritt für Schritt:

  1. Beim ersten Mount ist debouncedValue === value (Initialwert aus useState).
  2. Der useEffect startet einen setTimeout, der nach delay ms setDebouncedValue(value) ruft.
  3. Wenn der value sich vor Ablauf des Timers ändert, läuft der Cleanup (clearTimeout) — der alte Timer wird abgebrochen.
  4. Der Effect läuft neu — ein neuer Timer wird gestartet, mit dem neuen value.
  5. Erst wenn der value delay Millisekunden lang stabil bleibt, läuft der Timer durch und debouncedValue wird aktualisiert.

Der Trick liegt im Cleanup. useEffect-Cleanup läuft NICHT nur beim Unmount, sondern auch vor jedem neuen Run des Effects. Genau diese Eigenschaft macht das Pattern so elegant: jeder neue value-Wechsel bricht den noch-laufenden Timer ab und startet einen neuen.

Praxis-Beispiel: Such-Feld mit API

TypeScript SearchField.jsx
import { useState, useEffect } from 'react';
import { useDebounce } from './useDebounce';

export default function SearchField() {
    // Live-Wert: ändert sich pro Tastendruck
    const [query, setQuery] = useState('');

    // Debounced-Wert: ändert sich erst 300 ms nach der letzten Eingabe
    const debouncedQuery = useDebounce(query, 300);

    const [results, setResults] = useState([]);
    const [isLoading, setIsLoading] = useState(false);

    // API-Call hängt am DEBOUNCED Wert — nicht am Live-Wert
    useEffect(() => {
        if (!debouncedQuery) {
            setResults([]);
            return;
        }

        const controller = new AbortController();
        setIsLoading(true);

        fetch(`/api/search?q=${encodeURIComponent(debouncedQuery)}`, {
            signal: controller.signal,
        })
            .then(r => r.json())
            .then(data => {
                setResults(data.items);
                setIsLoading(false);
            })
            .catch(err => {
                if (err.name !== 'AbortError') {
                    setIsLoading(false);
                }
            });

        return () => controller.abort();
    }, [debouncedQuery]);

    return (
        <>
            <input
                type="text"
                value={query}
                onChange={(e) => setQuery(e.target.value)}
                placeholder="Suchen …"
            />
            {isLoading && <p>Lade …</p>}
            <ul>
                {results.map(item => (
                    <li key={item.id}>{item.title}</li>
                ))}
            </ul>
        </>
    );
}

Drei wichtige Beobachtungen:

  • Zwei State-Slots, zwei Sichten. Der <input> ist an query gebunden — sofort sichtbar, jeder Tastendruck wird angezeigt. Die API hängt an debouncedQuery — nachhinkend.
  • useEffect mit debouncedQuery als Dependency. Der Effect läuft NICHT pro Tastendruck, sondern nur, wenn debouncedQuery sich ändert — also alle 300 ms (oder seltener).
  • AbortController als Race-Schutz. Selbst mit Debounce kann es passieren, dass zwei Such-Sessions zeitlich überlappen. AbortController bricht den vorherigen Request ab, sobald ein neuer startet. Debounce + AbortController = produktionsreifes Such-Pattern.

Variante 2: useDebouncedCallback — die Funktion verzögern

Manchmal will man nicht den Wert verzögern, sondern die Funktion, die etwas tut. Beispiel: ein Save-Button, der nur einmal pro 500 ms wirklich speichert, egal wie oft der User klickt.

TypeScript useDebouncedCallback.js
import { useRef, useCallback, useEffect } from 'react';

export function useDebouncedCallback(callback, delay = 300) {
    const timerRef = useRef(null);

    // Aktuellen Callback in Ref halten — vermeidet Stale-Closure
    const callbackRef = useRef(callback);
    useEffect(() => {
        callbackRef.current = callback;
    }, [callback]);

    // Cleanup beim Unmount: laufenden Timer abbrechen
    useEffect(() => {
        return () => {
            if (timerRef.current) clearTimeout(timerRef.current);
        };
    }, []);

    const debouncedFn = useCallback((...args) => {
        // Alten Timer abbrechen, neuen starten
        if (timerRef.current) clearTimeout(timerRef.current);

        timerRef.current = setTimeout(() => {
            callbackRef.current(...args);
        }, delay);
    }, [delay]);

    return debouncedFn;
}

Die Unterschiede zu Variante 1:

  • Rückgabe ist eine Funktion, nicht ein Wert. Konsumenten rufen diese Funktion mit beliebigen Argumenten auf — der Aufruf wird verzögert.
  • useRef für den Timer, weil wir ihn zwischen Renders behalten müssen, ohne dass eine State-Änderung ein Re-Render triggert.
  • callbackRef mit Update-Effect: ohne diese Indirektion würde der setTimeout-Callback den callback-Wert vom Zeitpunkt des Setup der Debounce-Funktion benutzen — Stale-Closure. Mit dem Ref greifen wir bei der Ausführung auf den aktuellen Callback zu.
TypeScript VerwendungCallback.jsx
function AutoSaveEditor({ text, onSave }) {
    const debouncedSave = useDebouncedCallback(onSave, 1000);

    return (
        <textarea
            value={text}
            onChange={(e) => debouncedSave(e.target.value)}
        />
    );
}

Hier wird onSave nicht bei jedem Tastendruck aufgerufen, sondern erst 1 Sekunde nach dem letzten Tippen.

Wann welche Variante?

SituationEmpfehlung
Such-Feld mit API-CalluseDebounce(value) + useEffect
Live-Validierung mit Server-RoundtripuseDebounce(value)
Window-Resize / Scroll-Position-TrackinguseDebounce(value) mit Resize-State
Save-Button, der zu oft geklickt wirduseDebouncedCallback
Auto-Save während TippenuseDebouncedCallback
Bei Concurrent-Rendering, ohne externen API-CalluseDeferredValue (React 18+)

useDebounce vs. useDeferredValue — der wichtige Unterschied:

  • useDebounce wartet eine feste Zeit (z.B. 300 ms). Pro Wert-Wechsel ein neuer Timer.
  • useDeferredValue (React 18 Concurrent-Feature) wartet, bis React Zeit hat. Auf schnellen Geräten praktisch sofort, auf langsamen Geräten länger. Adaptiv an die Hardware-Last.

Für API-Calls ist useDebounce die richtige Wahl — feste Verzögerung ist berechenbar. Für teure UI-Renders ohne API (z.B. eine Liste filtern, die schon im Memory ist) ist useDeferredValue besser, weil es sich an die Render-Last anpasst.

Vergleich mit lodash.debounce

lodash.debounce ist die etablierte JavaScript-Debounce-Funktion außerhalb von React. Sie hat mehr Optionen (Leading-Edge-Mode, Maximum-Wait), aber ist nicht hook-friendly:

TypeScript LodashImHook.jsx
// Naive Verwendung — funktioniert NICHT
import debounce from 'lodash.debounce';

function Search() {
    const [query, setQuery] = useState('');

    // FALLE: bei jedem Render NEUE Debounce-Funktion
    const debouncedSetQuery = debounce(setQuery, 300);

    return <input onChange={e => debouncedSetQuery(e.target.value)} />;
}

Bei jedem Render entsteht eine neue Debounce-Funktion mit neuem internen State — der Timer wird nie ausgelöst, weil er immer wieder durch eine frische Instanz ersetzt wird. Lösung: in useMemo oder useRef stabilisieren.

TypeScript LodashKorrekt.jsx
import { useMemo } from 'react';
import debounce from 'lodash.debounce';

function Search() {
    const [query, setQuery] = useState('');

    // ✅ Stabilisiert: Debounce-Funktion bleibt zwischen Renders identisch
    const debouncedSetQuery = useMemo(
        () => debounce(setQuery, 300),
        []
    );

    return <input onChange={e => debouncedSetQuery(e.target.value)} />;
}

Funktional äquivalent zu unserem eigenen useDebouncedCallback. Für die meisten Cases ist der Eigenbau-Hook ausreichend und spart die Library-Dependency. Bei komplexen Anforderungen (Leading-Mode, Maximum-Wait, Throttle-Variante) lohnt sich lodash.

Debounce vs. Throttle — ein Wort zur Unterscheidung

Häufig verwechselt. Der praktische Unterschied:

  • Debounce: feuert NACH einer Pause. Wenn der User durchgehend tippt, feuert es nie. Sobald 300 ms ohne Eingabe vergehen, feuert es einmal.
  • Throttle: feuert regelmäßig alle N Millisekunden. Auch bei durchgehender Aktivität — einmal pro N ms.

Use-Case-Beispiel:

  • Tippen im Such-Feld → Debounce (warten, bis User fertig ist).
  • Mausbewegung tracken für Drag-and-Drop → Throttle (alle 16 ms ein Update, also 60 fps).
  • Scroll-Listener für „beim Scrollen Position merken" → Throttle (regelmäßig).
  • Window-Resize → meist Debounce (auf den finalen Zustand reagieren).

Besonderheiten

Der Trick im Cleanup: alter Timer wird abgebrochen, neuer startet.

useEffect-Cleanup läuft VOR jedem neuen Effect-Run, nicht nur beim Unmount. Diese Eigenschaft macht das Debounce-Pattern so elegant: jeder neue Wert bricht den noch laufenden Timer ab.

Live-Wert UND Debounced-Wert beide im State.

Der <input> ist an den Live-Wert gebunden — sofort sichtbar. Die teure Aktion (API-Call, Filter) ist an den Debounced-Wert gebunden — verzögert. So fühlt sich das Tippen flüssig an, ohne Server zu spammen.

Debounce ersetzt KEINEN AbortController — beides nötig.

Debounce reduziert die Anzahl der gestarteten Requests. AbortController verhindert, dass zwei gleichzeitig in Flug sind. Zusammen ergeben sie das produktionsreife Such-Pattern.

useDebouncedCallback braucht `useRef` für Timer UND Callback.

Timer in useRef — überlebt Re-Renders ohne neuen Trigger. Aktueller Callback in einem zweiten useRef — sonst greift der setTimeout-Callback auf den Wert vom Setup-Zeitpunkt zurück (Stale Closure).

useDeferredValue ist die Concurrent-Alternative ohne festen Delay.

Bei API-Calls: useDebounce (feste Verzögerung). Bei reinen UI-Updates (Filter einer großen Liste im Memory): useDeferredValue — adaptiv an die Render-Last.

debounce aus lodash braucht `useMemo`-Stabilisierung in React.

Direkt aufrufen erzeugt bei jedem Render eine neue Debounce-Instanz — der interne Timer wird nie ausgelöst. useMemo(() => debounce(...), []) stabilisiert. Bei einfachen Cases: Eigenbau-Hook spart die Dependency.

Debounce vs. Throttle: NACH einer Pause vs. REGELMÄSSIG.

Debounce: feuert, wenn User aufhört. Throttle: feuert konstant alle N ms. Such-Feld → Debounce. Scroll/Resize → meist Throttle. Beide haben ihre Daseinsberechtigung.

Default-Delay 300 ms ist die Faustregel für User-Input.

300 ms ist die in der UX-Forschung etablierte Schwelle, ab der eine Pause sich „bewusst" anfühlt. Für API-Anfragen ein guter Default. Bei Maus-Input oder Resize: 100-150 ms.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Custom Hooks

Zur Übersicht