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:
// 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.
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:
- Beim ersten Mount ist
debouncedValue === value(Initialwert aususeState). - Der
useEffectstartet einensetTimeout, der nachdelaymssetDebouncedValue(value)ruft. - Wenn der
valuesich vor Ablauf des Timers ändert, läuft der Cleanup (clearTimeout) — der alte Timer wird abgebrochen. - Der Effect läuft neu — ein neuer Timer wird gestartet, mit dem neuen
value. - Erst wenn der
valuedelayMillisekunden lang stabil bleibt, läuft der Timer durch unddebouncedValuewird 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
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 anquerygebunden — sofort sichtbar, jeder Tastendruck wird angezeigt. Die API hängt andebouncedQuery— nachhinkend. useEffectmitdebouncedQueryals Dependency. Der Effect läuft NICHT pro Tastendruck, sondern nur, wenndebouncedQuerysich ändert — also alle 300 ms (oder seltener).AbortControllerals 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.
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.
useReffür den Timer, weil wir ihn zwischen Renders behalten müssen, ohne dass eine State-Änderung ein Re-Render triggert.callbackRefmit Update-Effect: ohne diese Indirektion würde dersetTimeout-Callback dencallback-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.
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?
| Situation | Empfehlung |
|---|---|
| Such-Feld mit API-Call | useDebounce(value) + useEffect |
| Live-Validierung mit Server-Roundtrip | useDebounce(value) |
| Window-Resize / Scroll-Position-Tracking | useDebounce(value) mit Resize-State |
| Save-Button, der zu oft geklickt wird | useDebouncedCallback |
| Auto-Save während Tippen | useDebouncedCallback |
| Bei Concurrent-Rendering, ohne externen API-Call | useDeferredValue (React 18+) |
useDebounce vs. useDeferredValue — der wichtige Unterschied:
useDebouncewartet 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:
// 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.
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
- setTimeout – MDN
- useDeferredValue – react.dev
- lodash.debounce – Documentation
- Debounce vs Throttle – CSS-Tricks