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.
Apa yang sebenarnya dilakukan debouncing dan throttling
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 |
Pendekatan useEffect naif dan mengapa ia bocor
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.
Hook useDebounce yang dapat dipakai ulang
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)} />;
}Men-debounce callback tanpa stale closure
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.
Men-throttle handler scroll dan resize
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;
}Cleanup saat unmount adalah inti dari segalanya
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.
- Kembalikan clearTimeout atau clearInterval dari setiap effect yang membuatnya.
- Lepas setiap event listener yang Anda tambahkan, memakai referensi fungsi yang sama.
- Batalkan fetch yang sedang berjalan dengan AbortController agar respons terlambat tak menimpa state terbaru.
- Simpan callback dalam ref, atau gunakan useEffectEvent, agar fungsi debounce tak pernah menangkap props atau state yang basi.
- Untuk apa pun di luar beberapa titik pemakaian, gunakan pustaka yang teruji ketimbang mengimplementasikan ulang kasus tepi.
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.

