useDebounce — Retrasa actualizaciones de valores o ejecución de callbacks hasta que la entrada se estabilice
Receta
Dos hooks complementarios: useDebouncedValue retrasa un valor reactivo; useDebouncedCallback retrasa una llamada de función.
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
/**
* useDebouncedValue
* Devuelve una copia retrasada de `value` que solo se actualiza
* después de `delay` ms de inactividad.
*/
function useDebouncedValue<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
/**
* useDebouncedCallback
* Devuelve una versión estable retrasada de `callback`.
* Soporta invocación en el borde inicial a través de `options.leading`.
*/
function useDebouncedCallback<T extends (...args: any[]) => void>(
callback: T,
delay: number,
options: { leading?: boolean } = {}
): T & { cancel: () => void; flush: () => void } {
const { leading = false } = options;
const callbackRef = useRef(callback);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastArgsRef = useRef<Parameters<T> | null>(null);
const leadingFiredRef = useRef(false);
// Siempre mantener el callback más reciente
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
// Limpieza al desmontar
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
const cancel = useCallback(() => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = null;
lastArgsRef.current = null;
leadingFiredRef.current = false;
}, []);
const flush = useCallback(() => {
if (lastArgsRef.current) {
callbackRef.current(...lastArgsRef.current);
}
cancel();
}, [cancel]);
const debounced = useCallback(
(...args: Parameters<T>) => {
lastArgsRef.current = args;
if (leading && !leadingFiredRef.current) {
leadingFiredRef.current = true;
callbackRef.current(...args);
}
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
if (!leading) {
callbackRef.current(...args);
}
leadingFiredRef.current = false;
timerRef.current = null;
lastArgsRef.current = null;
}, delay);
},
[delay, leading]
) as T & { cancel: () => void; flush: () => void };
debounced.cancel = cancel;
debounced.flush = flush;
return debounced;
}Cuándo usarlo: Necesitas evitar hacer demasiadas solicitudes a una API en cada pulsación de tecla (búsqueda mientras escribes), o quieres agrupar eventos rápidos como resize o scroll en una única actualización.
Ejemplo Funcionando
"use client";
import { useState } from "react";
function SearchInput() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebouncedValue(query, 300);
// Solo dispara solicitud de red cuando debouncedQuery cambia
useEffect(() => {
if (!debouncedQuery) return;
fetch(`/api/search?q=${encodeURIComponent(debouncedQuery)}`)
.then((res) => res.json())
.then((data) => console.log(data));
}, [debouncedQuery]);
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar..."
/>
);
}
function SaveButton() {
const debouncedSave = useDebouncedCallback(
(content: string) => {
fetch("/api/save", {
method: "POST",
body: JSON.stringify({ content }),
});
},
1000,
{ leading: true }
);
return (
<button onClick={() => debouncedSave("draft content")}>
Guardar (retrasado)
</button>
);
}Lo que demuestra esto:
useDebouncedValueretrasa la consulta de búsqueda para que la API se llame solo después de que el usuario deje de escribir durante 300 msuseDebouncedCallbackconleading: truedispara inmediatamente al primer clic, luego ignora clics rápidos durante 1 segundo- Ambos hooks limpian automáticamente los timers al desmontar
Profundización
Cómo Funciona
- useDebouncedValue establece un
setTimeoutcada vez quevaluecambia. La función de limpieza enuseEffectborra el timer anterior, por lo que solo la última actualización dentro dedelayms realmente se registra en el state. - useDebouncedCallback almacena el callback más reciente en una ref para que los llamadores nunca necesiten preocuparse por cierres obsoletos. Una única ref de timer se reutiliza entre llamadas.
- La opción del borde inicial dispara el callback inmediatamente en la primera invocación, luego suprime llamadas posteriores hasta que el retraso transcurra sin actividad.
cancel()borra cualquier invocación pendiente.flush()invoca el callback pendiente inmediatamente y reinicia el state.- Ambos hooks se limpian al desmontar para prevenir advertencias de "setState on unmounted component".
Parámetros y Valores Devueltos
useDebouncedValue
| Parámetro | Tipo | Predeterminado | Descripción |
|---|---|---|---|
value | T | — | El valor a retrasar |
delay | number | — | Milisegundos a esperar |
| Devuelve | T | — | El valor retrasado |
useDebouncedCallback
| Parámetro | Tipo | Predeterminado | Descripción |
|---|---|---|---|
callback | (...args) => void | — | Función a retrasar |
delay | number | — | Milisegundos a esperar |
options.leading | boolean | false | Dispara en el borde inicial |
| Devuelve | T & { cancel, flush } | — | Función retrasada con cancel y flush |
Variaciones
Modo inmediato (leading + trailing): Dispara en ambos bordes manteniendo un registro de si la llamada trailing también debe dispararse. Útil para botones de guardar donde quieres retroalimentación instantánea más un guardado final.
// Dispara en ambos bordes inicial y final
function useDebouncedCallback(callback, delay, { leading: true, trailing: true })Con maxWait: Garantiza que el callback se dispare al menos cada maxWait ms incluso si la entrada nunca se detiene. Combina debounce con un timer maxWait para limitar el retraso.
Notas de TypeScript
useDebouncedValuees genérico sobreT, por lo que el tipo devuelto coincide automáticamente con el tipo de entrada.useDebouncedCallbackpreserva los tipos de parámetro del callback original a través deParameters<T>.- El patrón de tupla
as constno es necesario aquí ya que ambos hooks devuelven valores únicos u objetos.
Trampas
- Cierre obsoleto en callback — Si incluyes el callback directamente sin el patrón de ref, las variables capturadas pueden estar obsoletas cuando el timer se dispare. Solución: El patrón de ref en
useDebouncedCallbacksiempre llama a la versión más reciente. - Retraso de cero —
setTimeout(fn, 0)aún se difiere al siguiente tick, lo que puede causar un destello visible. Solución: Usa un guard comoif (delay <= 0) return callback(...)para casos de retraso cero. - Actualizaciones de state después de desmontar — Si el componente se desmonta antes de que el timer se dispare, React advierte sobre actualizaciones filtradas. Solución: La limpieza en
useEffectmaneja esto; asegúrate de no omitirla. - Desincronización de hidratación —
useDebouncedValuese inicializa con el valor sin procesar (sinsetTimeouten el servidor), por lo que no hay desincronización de hidratación. No se necesita manejo especial.
Alternativas
| Paquete | Nombre del Hook | Notas |
|---|---|---|
use-debounce (npm) | useDebounce, useDebouncedCallback | Más popular, soporta maxWait, leading/trailing |
usehooks-ts | useDebounce | Solo valores retrasados |
ahooks | useDebounce, useDebounceFn | Completo, parte de una colección grande |
lodash | _.debounce | No es un hook; envuelve en useMemo o useRef |
@uidotdev/usehooks | useDebounce | Minimal, solo valores |
Ejemplo del Mundo Real
De una aplicación SaaS Next.js 15 / React 19 en producción (SystemsArchitect.io).
// Ejemplo de producción: patrón debounce integrado con SWR
// Archivo: src/hooks/use-search.ts
import { useState, useEffect, useMemo } from 'react';
import useSWR from 'swr';
const fetcher = (url: string) => fetch(url).then((r) => r.json());
export function useSearch(query: string, platform: string) {
const [debouncedQuery, setDebouncedQuery] = useState(query);
// Debounce: solo actualiza la consulta después de 300ms de inactividad
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 300);
return () => clearTimeout(timer);
}, [query]);
const shouldFetch = debouncedQuery.length >= 3;
const apiUrl = shouldFetch
? `/api/search?q=${encodeURIComponent(debouncedQuery)}&platform=${platform}`
: null;
const { data, isLoading, isValidating } = useSWR(apiUrl, fetcher, {
keepPreviousData: true,
});
return useMemo(() => ({
results: data?.results ?? [],
isLoading: isLoading || (shouldFetch && isValidating),
}), [data, isLoading, shouldFetch, isValidating]);
}Lo que demuestra en producción:
- El
setTimeout+clearTimeoutlimpieza enuseEffectes el mecanismo debounce central. Cada vez quequerycambia, el timeout anterior se borra y uno nuevo comienza. Solo cuando el usuario deja de escribir durante 300mssetDebouncedQueryse dispara. - Este es el patrón
useDebouncedValueaplicado directamente en línea. En producción, se mantuvieron en línea en lugar de extraer a un hook separado porque el debounce está estrechamente acoplado con la lógica de fetch de SWR y la verificación de longitud mínima. - La función de limpieza
return () => clearTimeout(timer)es crítica. Sin ella, una escritura rápida programaría múltiples timeouts, y consultas obsoletas se dispararían fuera de orden. La limpieza garantiza que solo el último timeout sobreviva. - La convención de clave
nullde SWR (cuandoshouldFetches falso) previene el fetch completamente. No se realiza solicitud hasta que la consulta retrasada alcanza 3 caracteres. keepPreviousData: truepreviene un destello de resultados en blanco mientras se recupera la nueva consulta retrasada. Los resultados anteriores permanecen visibles hasta que llegan datos frescos.
Preguntas Frecuentes
¿Cuál es la diferencia entre useDebouncedValue y useDebouncedCallback?
useDebouncedValuetoma un valor reactivo y devuelve una copia retrasada que solo se actualiza después de que el retraso transcurra.useDebouncedCallbacktoma una función y devuelve una versión retrasada de esa función.- Usa
useDebouncedValuecuando quieras retrasar un valor alimentando unuseEffect. UsauseDebouncedCallbackcuando quieras retrasar una acción como un clic de botón o guardar.
¿Por qué useDebouncedCallback almacena el callback en una ref en lugar de usarlo directamente?
- Sin el patrón de ref, el callback capturado por
setTimeoutvería valores de cierre obsoletos del renderizado cuando se creó el timer. - La ref (
callbackRef.current) se actualiza en cada renderizado, por lo que el timer siempre llama a la versión más reciente del callback.
¿Cómo pausas o cancelas una llamada debounced pendiente?
Llama a cancel() en la función retrasada devuelta:
const debouncedSave = useDebouncedCallback(save, 1000);
// Más tarde:
debouncedSave.cancel();¿Qué hace el método flush y cuándo lo usarías?
flush()invoca inmediatamente el callback pendiente con los últimos argumentos y reinicia el state.- Úsalo cuando el usuario navega o envía un formulario y necesitas garantizar que la acción retrasada se dispare ahora mismo.
¿Qué sucede si el componente se desmonta antes de que el timer debounce se dispare?
- La función de limpieza en
useEffectllama aclearTimeout, cancelando el timer pendiente. - Esto previene advertencias de "setState on unmounted component".
¿Qué hace la opción leading en useDebouncedCallback?
- Cuando
leadingestrue, el callback se dispara inmediatamente en la primera invocación. - Las llamadas posteriores dentro de la ventana de retraso se suprimen.
- Esto es útil para botones de guardar donde quieres retroalimentación instantánea en el primer clic.
Trampa: ¿Qué sucede si pasas un retraso de cero?
setTimeout(fn, 0)aún difiere la ejecución al siguiente tick, lo que puede causar un destello visible.- Si necesitas ejecución síncrona con retraso cero, añade un guard:
if (delay <= 0) return callback(...).
Trampa: ¿Por qué podría el callback retrasado dispararse con datos obsoletos si omites el patrón de ref?
- Los cierres de JavaScript capturan variables en tiempo de creación.
- Sin actualizar
callbackRef.currenten cada renderizado, el cierre desetTimeoutharía referencia al callback del renderizado cuando se estableció el timer, no al más reciente.
¿Cómo previene el ejemplo de SWR en el mundo real obtener consultas cortas?
- Verifica
debouncedQuery.length >= 3antes de construir la URL de la API. - Cuando la condición es falsa, pasa
nullcomo clave de SWR, lo que le dice a SWR que omita el fetch completamente.
¿Por qué el ejemplo de producción usa keepPreviousData: true con SWR?
- Previene un destello de resultados en blanco mientras se recupera la nueva consulta retrasada.
- Los resultados anteriores permanecen visibles hasta que llegan datos frescos, proporcionando una experiencia de usuario más suave.
¿Cómo infiere TypeScript el tipo de devolución de useDebouncedValue?
- El hook es genérico sobre
T, por lo que el tipo de devolución coincide automáticamente con el tipo de entrada. - No se necesita anotación de tipo explícita al llamar al hook.
const debounced = useDebouncedValue("hello", 300);
// debounced se infiere como string¿Cómo preserva useDebouncedCallback los tipos de parámetro de la función original en TypeScript?
- El
T extends (...args: any[]) => voidgenérico captura la firma de función completa. Parameters<T>extrae los tipos de argumento para que el envoltorio retrasado acepte los mismos argumentos que el callback original.
Relacionado
- useThrottle — limita la velocidad en lugar de retrasar
- useFetch — combina con consultas retrasadas
- useEffect — la primitiva subyacente