useThrottle — Limita una valor o callback para que se dispare como máximo una vez por intervalo
Receta
import { useState, useEffect, useRef, useCallback } from "react";
/**
* useThrottledValue
* Devuelve una copia throttled de `value` que se actualiza como máximo
* una vez cada `interval` ms.
*/
function useThrottledValue<T>(value: T, interval: number): T {
const [throttled, setThrottled] = useState(value);
const lastUpdated = useRef(Date.now());
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const now = Date.now();
const elapsed = now - lastUpdated.current;
if (elapsed >= interval) {
setThrottled(value);
lastUpdated.current = now;
} else {
// Programa una actualización final
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
setThrottled(value);
lastUpdated.current = Date.now();
}, interval - elapsed);
}
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [value, interval]);
return throttled;
}
/**
* useThrottledCallback
* Devuelve una versión stable, throttled de `callback` que
* se ejecuta como máximo una vez cada `interval` ms.
*/
function useThrottledCallback<T extends (...args: any[]) => void>(
callback: T,
interval: number
): T & { cancel: () => void } {
const callbackRef = useRef(callback);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastCalledRef = useRef(0);
const lastArgsRef = useRef<Parameters<T> | null>(null);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
const cancel = useCallback(() => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = null;
lastArgsRef.current = null;
}, []);
const throttled = useCallback(
(...args: Parameters<T>) => {
lastArgsRef.current = args;
const now = Date.now();
const elapsed = now - lastCalledRef.current;
if (elapsed >= interval) {
callbackRef.current(...args);
lastCalledRef.current = now;
} else if (!timerRef.current) {
timerRef.current = setTimeout(() => {
if (lastArgsRef.current) {
callbackRef.current(...lastArgsRef.current);
}
lastCalledRef.current = Date.now();
timerRef.current = null;
lastArgsRef.current = null;
}, interval - elapsed);
}
},
[interval]
) as T & { cancel: () => void };
throttled.cancel = cancel;
return throttled;
}Cuándo usarlo: Necesitas actualizaciones consistentes y espaciadas durante eventos continuos como scrolling, resizing o movimiento del mouse, en lugar de esperar a que el evento se detenga (eso sería debounce).
Ejemplo en funcionamiento
"use client";
import { useState, useEffect, useRef } from "react";
function ScrollTracker() {
const [scrollY, setScrollY] = useState(0);
const handleScroll = useThrottledCallback(() => {
setScrollY(window.scrollY);
}, 100);
useEffect(() => {
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
return (
<div style={{ position: "fixed", top: 10, right: 10 }}>
Scroll: {scrollY}px
</div>
);
}
function ResizeDisplay() {
const [width, setWidth] = useState(
typeof window !== "undefined" ? window.innerWidth : 0
);
const throttledWidth = useThrottledValue(width, 200);
useEffect(() => {
const handler = () => setWidth(window.innerWidth);
window.addEventListener("resize", handler);
return () => window.removeEventListener("resize", handler);
}, []);
return (
<p>
Raw: {width}px — Throttled: {throttledWidth}px
</p>
);
}Qué demuestra esto:
useThrottledCallbackdispara el scroll handler como máximo cada 100 ms, manteniendo la UI responsiva sin inundar actualizaciones de stateuseThrottledValuesuaviza los valores raw de resize para actualizarse como máximo cada 200 ms- Ambas incluyen una llamada final para que el valor final nunca se pierda
Profundización
Cómo funciona
- Llamada inicial: La primera invocación dentro de un intervalo se dispara inmediatamente, dando feedback instantáneo al usuario.
- Llamada final: Un timeout captura los argumentos más recientes para que el valor final durante entrada rápida nunca se descarte.
- Patrón ref: El callback más reciente se almacena en un ref para evitar cierres obsoletos y re-renders innecesarios por cambios de dependencia.
- Limpieza: Todos los timers se limpian al desmontar para prevenir memory leaks.
Debounce vs Throttle
| Comportamiento | Debounce | Throttle |
|---|---|---|
| Cuándo se dispara | Después de que la entrada se detiene por N ms | Como máximo una vez cada N ms |
| Mejor para | Input de búsqueda, validación de formularios | Scroll, resize, drag |
| Responsividad | Se siente demorado | Se siente smooth |
| Valor final | Solo el último | Inicial + final |
Parámetros y valores de retorno
useThrottledValue
| Parámetro | Tipo | Predeterminado | Descripción |
|---|---|---|---|
value | T | — | El valor a throttle |
interval | number | — | Mínimo de ms entre actualizaciones |
| Devuelve | T | — | El valor throttled |
useThrottledCallback
| Parámetro | Tipo | Predeterminado | Descripción |
|---|---|---|---|
callback | (...args) => void | — | Función a throttle |
interval | number | — | Mínimo de ms entre llamadas |
| Devuelve | T & \{ cancel \} | — | Función throttled con cancel |
Variaciones
Throttle solo inicial: Salta la llamada final si solo quieres el primer evento en cada ventana. Elimina la rama de setTimeout final.
Throttle con requestAnimationFrame: Para actualizaciones visuales, reemplaza el timer con requestAnimationFrame para throttling perfecto de frame de 16 ms:
function useRAFCallback(callback: () => void) {
const rafRef = useRef(0);
const callbackRef = useRef(callback);
callbackRef.current = callback;
return useCallback(() => {
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => callbackRef.current());
}, []);
}Notas de TypeScript
- Generic
Ten el value hook preserva el tipo de entrada. Parameters<T>en el callback hook preserva los tipos de argumentos.- El tipo intersección
T & \{ cancel \}agrega métodos de control sin perder la firma original.
Trampas
- Falta la llamada final — Sin la rama final, el último valor en un burst se pierde. Solución: Siempre incluye un timer final (como se muestra en la receta).
- Intervalo demasiado corto — Establecer
intervalpor debajo de 16 ms no proporciona beneficio ya que el navegador no puede renderizar más rápido que un frame. Solución: Usa 16 ms mínimo, o cambia arequestAnimationFrame. - Drift del timer —
setTimeoutno es perfectamente preciso. Para código crítico en animaciones, Solución: usarequestAnimationFrameen su lugar. - Crash de SSR — Acceder a
windowdurante server render lanza un error. Solución: Protege contypeof window !== "undefined"o inicializa a un valor seguro.
Alternativas
| Paquete | Nombre del hook | Notas |
|---|---|---|
usehooks-ts | useThrottle | Throttle de valor solamente |
ahooks | useThrottle, useThrottleFn | Con todas las características, configuración inicial/final |
@uidotdev/usehooks | useThrottle | Throttle de valor mínimo |
lodash | _.throttle | No es un hook; envuelve en useRef |
use-debounce | useThrottledCallback | Parte del paquete debounce |
FAQs
¿Cuál es la diferencia entre useThrottledValue y useThrottledCallback?
useThrottledValueacepta un valor reactivo y devuelve una copia throttled que se actualiza como máximo una vez por intervalo.useThrottledCallbackacepta una función y devuelve una versión throttled de esa función.- Usa la variante de valor cuando ya tienes state cambiando frecuentemente (ej., resize width).
- Usa la variante de callback cuando quieres throttle un event handler directamente (ej., scroll handler).
¿Por qué el hook almacena el callback en un ref en lugar de pasarlo directamente a setTimeout?
Almacenar el callback en callbackRef asegura que el timeout siempre llame a la versión más reciente de la función. Sin él, el closure capuraría un callback obsoleto de un render anterior.
¿Qué sucede si establezco el intervalo en 0 o un número muy pequeño como 5 ms?
- El navegador no puede renderizar más rápido que un frame (~16 ms), así que intervalos por debajo de 16 ms no proporcionan beneficio visual.
setTimeouttambién tiene un retraso mínimo de ~4 ms en la mayoría de navegadores.- Para actualizaciones perfectas de frame, usa
requestAnimationFrameen su lugar.
¿Cómo cancelo un callback throttled pendiente?
Llama al método .cancel() en la función devuelta:
const throttled = useThrottledCallback(handler, 200);
// Después:
throttled.cancel();¿Se perderá el último valor en un burst rápido?
No. Ambos hooks incluyen una llamada final vía setTimeout. Cuando el burst termina, el timer final se dispara con el valor o argumentos más recientes, asegurando que nada se descarte.
Trampa: ¿Por qué mi scroll handler throttled se cuelga durante SSR?
- Acceder a
windowdurante server-side rendering lanza un ReferenceError. - Protege con
typeof window !== "undefined"antes de leerwindow.scrollYo adjuntar listeners. - El
useEffecten el ejemplo en funcionamiento solo se ejecuta en el cliente, pero el estado inicial también debe ser seguro.
Trampa: Removí la rama trailing setTimeout y ahora mi UI muestra datos obsoletos. ¿Por qué?
Sin la llamada final, solo se dispara la invocación inicial. Si el valor cambia durante la ventana de cooldown, la actualización final se descarta silenciosamente. Siempre mantén la rama final para capturar el último valor.
¿Cómo difiere la variación requestAnimationFrame del throttle basado en timer?
requestAnimationFramese dispara una vez por display frame (~16 ms a 60 Hz).- Se adapta automáticamente a la frecuencia de actualización del monitor.
- Es ideal para actualizaciones visuales como animaciones o redibujados de canvas.
- El throttle basado en timer te permite elegir cualquier intervalo y es mejor para trabajo no visual.
¿Cómo preserva TypeScript los tipos de argumento del callback throttled?
La firma genérica T extends (...args: any[]) => void captura el tipo de función original. Parameters<T> extrae la tupla de argumentos, así que el wrapper throttled acepta los mismos parámetros que el original.
¿Qué hace el tipo intersección T & \{ cancel: () => void \} en TypeScript?
Combina el tipo de función original T con un objeto que tiene un método cancel. Esto significa que la función devuelta es callable con la misma firma que la original, y también expone .cancel() para limpieza.
¿Cuándo debo usar throttle en lugar de debounce?
- Throttle cuando necesitas actualizaciones periódicas durante entrada continua (posición de scroll, coordenadas de drag, resize).
- Debounce cuando solo te importa el valor final después de que la entrada se detiene (consultas de búsqueda, validación de formularios).
- Throttle se siente responsivo; debounce se siente demorado.
Relacionado
- useDebounce — retrasa hasta que la entrada se detiene
- useWindowSize — un caso de uso común de throttle
- useEventListener — vinculación de eventos declarativa