Debouncing dan Throttling di React dengan Cara yang Benar

Foto oleh Ansgar Koreng via Wikimedia Commons (CC BY-SA 4.0)
Debouncing menunggu jeda: ia hanya menjalankan fungsi setelah event berhenti selama penuh delay, ideal untuk input pencarian dan autosave. Throttling menjalankan fungsi maksimal sekali per interval tak peduli berapa banyak event yang datang, cocok untuk handler scroll dan resize. Singkatnya, debounce peduli pada nilai terakhir setelah aktivitas mereda, sedangkan throttle menegakkan laju pengambilan sampel yang stabil selama aktivitas.
Hampir selalu karena effect memulai setTimeout tetapi tidak pernah mengembalikan cleanup yang membersihkannya. Setiap render lalu menjadwalkan timer baru tanpa membatalkan yang sebelumnya, sehingga sepuluh ketikan memicu sepuluh panggilan. Mengembalikan fungsi cleanup yang memanggil clearTimeout membatalkan timer tertunda sebelum eksekusi berikutnya dan saat unmount, yang memperbaiki baik panggilan ganda maupun memory leak.
Kembalikan fungsi cleanup dari useEffect yang memiliki timer itu, dan React menjalankannya otomatis saat unmount maupun sebelum setiap eksekusi ulang. Untuk callback debounce yang disimpan dalam ref, tambahkan effect terpisah dengan dependency array kosong yang cleanup-nya memanggil clearTimeout pada ref. Ini mencegah timer memicu setState pada komponen yang sudah unmount.
Debounce sebuah nilai saat Anda menurunkan sesuatu darinya, seperti query pencarian yang memicu fetch — useDebounce mengembalikan nilai yang sudah mereda dan menjaga komponen tetap sederhana. Debounce sebuah callback saat Anda memicu aksi seperti simpan atau event analitik. Versi callback butuh ref ke fungsi terbaru agar tidak memicu stale closure.
useEffectEvent, stabil sejak React 19.2, memecahkan separuh masalah stale-closure dengan memberi Anda fungsi yang selalu membaca props dan state terbaru tanpa masuk ke dependency array. Anda tetap butuh timer dan cleanup-nya untuk debouncing yang sebenarnya. Banyak tim juga lebih memilih pustaka terpelihara seperti use-debounce atau Lodash untuk opsi rumit seperti maxWait, leading, dan trailing edge.

Foto oleh Ansgar Koreng via Wikimedia Commons (CC BY-SA 4.0)
Ringkasan Utama
Debouncing menunda sebuah fungsi hingga input berhenti; throttling membatasinya maksimal sekali per interval. Di React, letakkan timer di dalam useEffect dan selalu kembalikan cleanup yang membersihkannya. Cleanup itu membatalkan timer yang tertunda sebelum eksekusi berikutnya dan saat unmount, dan itulah yang mencegah timer bocor, panggilan API ganda, serta stale closure.
Setiap aplikasi React pada akhirnya punya input yang terlalu sering terpicu. Kotak pencarian yang memanggil API di setiap ketikan, handler scroll yang menghitung ulang layout enam puluh kali per detik, autosave yang menulis di setiap karakter. Debouncing dan throttling adalah dua alat untuk menjinakkannya, dan keduanya mudah sekali salah secara halus di React karena timer hidup melintasi render sementara komponen Anda terus-menerus membuat ulang closure di sekitarnya.
Saya menjalankan stack self-hosted di mana endpoint pencarian yang di-debounce berada di depan query full-text PostgreSQL. Versi pertama membocorkan timer dan membebani database di setiap penekanan tombol. Inilah panduan yang saya harap ada saat itu: apa yang sebenarnya dilakukan kedua teknik ini, mengapa versi useEffect yang naif bocor, dan sekumpulan kecil hook yang dapat dipakai ulang dan membersihkan dirinya dengan benar.
Debouncing menunggu jeda. Ia memulai ulang timer di setiap event dan hanya menjalankan fungsi setelah event berhenti selama penuh delay. Throttling justru membiarkan fungsi berjalan segera, lalu memblokirnya sampai interval tetap berlalu, memberi Anda irama stabil tak peduli seberapa cepat event datang. Perbedaannya penting: debounce soal nilai terakhir setelah aktivitas mereda, throttle soal laju pengambilan sampel yang dapat diprediksi selama aktivitas berlangsung.
| Aspek | Debounce | Throttle |
|---|---|---|
| Terpicu saat | Aktivitas berhenti selama penuh delay | Maksimal sekali per interval, selama aktif |
| Paling cocok untuk | Input pencarian, validasi form, autosave | Scroll, resize, mousemove, drag |
| Kasus terburuk | Jika input tak pernah jeda, ia tak pernah terpicu | Terpicu pada irama tetap apa pun kondisinya |
| Rasa bagi pengguna | Menunggu Anda selesai | Pembaruan stabil dan berkelanjutan |
| Delay tipikal | 300 hingga 500 ms | 100 hingga 250 ms |
Percobaan pertama yang menggoda adalah menjadwalkan setTimeout di dalam useEffect yang bergantung pada nilai input, lalu berhenti di situ. Tampak berfungsi di demo, lalu ambruk saat pengetikan nyata. Karena tidak ada cleanup, setiap ketikan menjalankan ulang effect dan memulai timer baru tanpa membatalkan yang sebelumnya. Ketik sepuluh karakter dan Anda menjadwalkan sepuluh panggilan API yang semuanya akan terpicu.
// The naive version — a new timer on every render, no cleanup
function SearchBox() {
const [query, setQuery] = useState("");
useEffect(() => {
// BUG: this timer is never cleared. Type 10 characters and you
// schedule 10 requests, and every one of them fires.
setTimeout(() => {
fetch(`/api/search?q=${query}`);
}, 500);
}, [query]);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}Jika komponen unmount saat timer masih tertunda, timer itu tetap terpicu dan bisa memanggil setState pada komponen yang sudah unmount atau merujuk props yang tak ada lagi. Timer yang tak dibersihkan adalah sumber memory-leak klasik di React: zombie timer yang menjaga closure-nya tetap hidup dan mengerjakan sesuatu yang tak seorang pun tunggu.
Primitif paling bersih men-debounce sebuah nilai, bukan callback. Anda mengembalikan fungsi cleanup dari useEffect, dan React menjalankannya sebelum effect berikutnya dan sekali lagi saat unmount. Setiap ketikan membatalkan timer sebelumnya, sehingga hanya nilai yang bertahan melewati penuh periode hening yang masuk ke state. Inilah satu baris terpenting di hook ini, dan justru yang dihilangkan versi naif.
import { useEffect, useState } from "react";
export function useDebounce<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState<T>(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delayMs);
// Cleanup runs before the next effect AND on unmount:
// it cancels the pending timer so only the last value survives.
return () => clearTimeout(id);
}, [value, delayMs]);
return debounced;
}Kini konsumennya tetap sederhana. Lacak input mentah di state, alirkan lewat useDebounce, dan jalankan effect Anda pada nilai yang telah di-debounce. Padukan fetch dengan AbortController agar permintaan yang sedang berjalan dibatalkan jika nilai debounce berubah lagi sebelum ia selesai, sehingga mencegah respons yang datang tidak berurutan menimpa hasil yang lebih baru.
function SearchBox() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const debouncedQuery = useDebounce(query, 400);
useEffect(() => {
if (!debouncedQuery) return;
const controller = new AbortController();
fetch(`/api/search?q=${debouncedQuery}`, { signal: controller.signal })
.then((r) => r.json())
.then(setResults)
.catch(() => {}); // ignore aborts
return () => controller.abort();
}, [debouncedQuery]);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}Kadang Anda perlu men-debounce sebuah aksi, bukan menurunkan nilai: tombol simpan, event analitik, perhitungan ulang akibat resize. Jebakannya di sini adalah stale closure. Jika Anda menangkap callback sekali saja, fungsi yang di-debounce terus memanggil versi lama yang melihat props dan state lama. Solusinya adalah menyimpan callback terbaru dalam sebuah ref dan memperbaruinya di setiap render, agar timer selalu memanggil fungsi terkini sementara identitas fungsi debounce tetap stabil.
import { useCallback, useEffect, useRef } from "react";
export function useDebouncedCallback<A extends unknown[]>(
callback: (...args: A) => void,
delayMs: number,
) {
const timer = useRef<ReturnType<typeof setTimeout>>();
// Keep the latest callback in a ref so the debounced function
// always sees fresh props/state — no stale closure.
const latest = useRef(callback);
useEffect(() => {
latest.current = callback;
});
const debounced = useCallback(
(...args: A) => {
clearTimeout(timer.current);
timer.current = setTimeout(() => latest.current(...args), delayMs);
},
[delayMs],
);
// Cancel any pending call when the component unmounts.
useEffect(() => () => clearTimeout(timer.current), []);
return debounced;
}Pola ref-plus-effect ini persis masalah yang dipecahkan React 19.2 dengan useEffectEvent: identitas fungsi yang stabil yang selalu membaca nilai terbaru tanpa masuk ke dependency array. Jika Anda memakai 19.2 atau lebih baru, Anda bisa mengandalkannya alih-alih membuat sendiri ref latest-callback untuk bagian logika yang non-reaktif.
Untuk event berfrekuensi tinggi, debounce adalah alat yang salah: handler scroll yang hanya terpicu setelah Anda berhenti scroll terasa rusak. Anda ingin throttle, berjalan maksimal sekali per interval. Throttle berbasis timestamp adalah bentuk benar yang paling sederhana. Dan yang krusial, apa pun yang Anda pasang dengan addEventListener harus dilepas di cleanup effect yang sama, atau Anda membocorkan listener di setiap mount.
import { useCallback, useEffect, useRef } from "react";
export function useThrottledCallback<A extends unknown[]>(
callback: (...args: A) => void,
intervalMs: number,
) {
const lastRun = useRef(0);
const latest = useRef(callback);
useEffect(() => {
latest.current = callback;
});
return useCallback(
(...args: A) => {
const now = Date.now();
if (now - lastRun.current >= intervalMs) {
lastRun.current = now;
latest.current(...args);
}
},
[intervalMs],
);
}
// Usage: throttle a scroll handler to at most once per 200ms
function Header() {
const onScroll = useThrottledCallback(() => {
// read scrollY, toggle a shadow, etc.
}, 200);
useEffect(() => {
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, [onScroll]);
return null;
}Entah Anda men-debounce nilai, men-debounce callback, atau men-throttle event, disiplin yang sama berlaku. Setiap timer, listener, atau subscription yang Anda mulai harus dibongkar dalam fungsi cleanup. React menjalankan cleanup itu sebelum menjalankan ulang effect dan sekali terakhir saat komponen unmount, yang mencakup baik kasus re-render maupun kasus pembongkaran.
Anda tidak selalu harus menulis ini sendiri. Paket use-debounce di npm memberi Anda useDebounce dan useDebouncedCallback dengan cancel, flush, dan maxWait yang sudah ditangani, dan Lodash debounce serta throttle tetap menjadi implementasi rujukan untuk opsi leading dan trailing edge yang sering salah di versi buatan tangan.
Gunakan hook kustom saat Anda ingin nol dependensi dan kontrol penuh, dan gunakan pustaka saat Anda butuh opsi yang rumit. Bagaimanapun, model mentalnya sama: timer hidup lebih lama dari sebuah render, jadi sebuah render tak boleh pernah meninggalkan satu pun di belakang.