Что такое дебаунс (debounce) в React и как реализовать его через кастомный хук?
Суть дебаунса
Дебаунс (debounce) — приём, который откладывает выполнение функции до момента, когда с последнего вызова прошло определённое время без новых вызовов. Если вызовы продолжают поступать, таймер каждый раз сбрасывается, и функция срабатывает только один раз — после паузы. Это отличает дебаунс от троттлинга (throttle), который гарантирует выполнение не чаще, чем раз в N миллисекунд, независимо от того, продолжаются вызовы или нет.
В React дебаунс чаще всего нужен там, где пользовательский ввод генерирует много событий подряд: поле поиска, автосохранение формы, обработка resize/scroll, валидация в реальном времени. Без дебаунса каждый символ, введённый в поисковую строку, может вызывать сетевой запрос или тяжёлый пересчёт.
Почему не просто lodash.debounce
Можно взять debounce из lodash и обернуть обработчик, но в React есть нюанс: функция-обработчик пересоздаётся на каждом рендере, а вместе с ней и debounce-обёртка, если создавать её прямо в теле компонента. Из-за этого таймер каждый раз обнуляется заново, и дебаунс перестаёт работать как задумано. Поэтому дебаунс-функцию нужно мемоизировать (например, через useMemo или useRef) либо создавать хук, который дебаунсит само значение, а не обработчик.
Хук useDebounce для значения
Самый распространённый и предсказуемый подход — дебаунсить не колбэк, а значение состояния. Хук принимает исходное значение и задержку, а возвращает «отложенную» копию значения, которая обновляется только после паузы.
Логика:
- Храним отложенное значение в состоянии.
- В
useEffect, зависящем от исходного значения, ставимsetTimeout, который черезdelayмс обновит отложенное значение. - В cleanup-функции эффекта очищаем предыдущий таймер — именно это и даёт эффект дребезга: каждое новое изменение отменяет предыдущий отложенный апдейт.
Хук useDebouncedCallback для функций
Иногда нужно дебаунсить не значение, а сам вызов функции (например, отправку запроса на сервер). Тогда таймер хранят в useRef, чтобы он не терялся между рендерами, а саму функцию оборачивают в useCallback, чтобы не создавать новую дебаунс-обёртку на каждый рендер.
Частые ошибки при реализации
- Забывают чистить таймер в cleanup-функции
useEffect— это приводит к утечкам и «призрачным» обновлениям после размонтирования компонента. - Создают debounce-функцию заново на каждом рендере без
useRef/useMemo, из-за чего таймер никогда не накапливается и дебаунс не срабатывает. - Путают дебаунс с троттлингом и применяют не тот подход к задаче (например, для скролла обычно нужен throttle, а не debounce).
- Забывают учитывать актуальность замыкания (stale closure) — колбэк внутри debounce может держать устаревшие значения props/state, если не использовать актуальные ref или зависимости.
- Не отменяют «зависший» сетевой запрос при быстрой смене ввода, из-за чего может прийти устаревший ответ и перезаписать актуальные данные (race condition).
Когда дебаунс не нужен
Если обновления происходят редко или их обработка дешёвая, дебаунс добавляет только сложность. Также для UI, где важна мгновенная обратная связь (например, подсветка ошибки при вводе), лучше подходит throttle или комбинация: мгновенный локальный отклик плюс дебаунс только для тяжёлой части (запрос на сервер).
Что хочет услышать интервьюер
Кандидат чётко объясняет разницу между debounce и throttle
Понимание, зачем в React дебаунс выносят в кастомный хук, а не пишут inline
Знание, что таймер нужно хранить в useRef, а очищать — в cleanup useEffect
Упоминание проблемы пересоздания функции на каждом рендере и роли useCallback/useMemo
Практический пример использования: поиск, автосохранение, валидация
Пример: Хук useDebounce для значения
import { useState, useEffect } from 'react';
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
// ставим таймер на обновление отложенного значения
const timerId = setTimeout(() => {
setDebouncedValue(value);
}, delay);
// при новом изменении value отменяем предыдущий таймер
return () => clearTimeout(timerId);
}, [value, delay]);
return debouncedValue;
}
// использование в поиске
function SearchInput() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 400);
useEffect(() => {
if (!debouncedQuery) return;
// запрос уйдёт только через 400мс после последнего ввода
fetch(`/api/search?q=${debouncedQuery}`);
}, [debouncedQuery]);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
Пример: Хук useDebouncedCallback для функций
import { useRef, useCallback, useEffect } from 'react';
function useDebouncedCallback<Args extends unknown[]>(
callback: (...args: Args) => void,
delay: number
) {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const callbackRef = useRef(callback);
// всегда держим актуальную ссылку на колбэк, чтобы избежать stale closure
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
return useCallback(
(...args: Args) => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
callbackRef.current(...args);
}, delay);
},
[delay]
);
}
Типичные ошибки
Не очищают setTimeout в cleanup-функции useEffect, что приводит к утечкам памяти
Создают debounce-обёртку заново на каждом рендере без useRef или useMemo
Путают debounce и throttle и предлагают неверное решение под задачу
Не учитывают stale closure — колбэк использует устаревшие значения из замыкания
Не обрабатывают race condition при отмене устаревших сетевых запросов


