useInterval — setInterval que funciona correctamente con el estado de React y limpieza
Receta
Basado en el patrón useInterval de Dan Abramov, extendido con pausa/reanudación y retraso dinámico.
import { useEffect, useRef, useCallback, useState } from "react";
/**
* useInterval
* Un hook setInterval declarativo. Limpia automáticamente al desmontar.
* Pasa `null` como delay para pausar.
*/
function useInterval(
callback: () => void,
delay: number | null
): void {
const savedCallback = useRef(callback);
// Recuerda el callback más reciente sin reiniciar el intervalo
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (delay === null) return;
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
/**
* useCountdown
* Temporizador de cuenta regresiva construido sobre useInterval.
*/
interface UseCountdownOptions {
/** Valor inicial en segundos */
seconds: number;
/** Intervalo en ms. Predeterminado: 1000 */
interval?: number;
/** Comenzar inmediatamente. Predeterminado: false */
autoStart?: boolean;
/** Llamado cuando la cuenta regresiva llega a cero */
onComplete?: () => void;
}
interface UseCountdownReturn {
/** Segundos restantes */
remaining: number;
/** Si la cuenta regresiva está en ejecución */
isRunning: boolean;
/** Inicia o reanuda la cuenta regresiva */
start: () => void;
/** Pausa la cuenta regresiva */
pause: () => void;
/** Reinicia al valor inicial y detiene */
reset: () => void;
}
function useCountdown(options: UseCountdownOptions): UseCountdownReturn {
const {
seconds,
interval = 1000,
autoStart = false,
onComplete,
} = options;
const [remaining, setRemaining] = useState(seconds);
const [isRunning, setIsRunning] = useState(autoStart);
const onCompleteRef = useRef(onComplete);
useEffect(() => {
onCompleteRef.current = onComplete;
}, [onComplete]);
useInterval(
() => {
setRemaining((prev) => {
if (prev <= 1) {
setIsRunning(false);
onCompleteRef.current?.();
return 0;
}
return prev - 1;
});
},
isRunning ? interval : null
);
const start = useCallback(() => {
if (remaining > 0) setIsRunning(true);
}, [remaining]);
const pause = useCallback(() => setIsRunning(false), []);
const reset = useCallback(() => {
setIsRunning(false);
setRemaining(seconds);
}, [seconds]);
return { remaining, isRunning, start, pause, reset };
}Cuándo usarlo: Necesitas un temporizador, bucle de polling, auto-actualización o cuenta regresiva que interactúe con el estado de React sin sufrir errores de cierre obsoleto.
Ejemplo en funcionamiento
"use client";
// Contador incrementado automáticamente
function TickCounter() {
const [count, setCount] = useState(0);
const [delay, setDelay] = useState<number | null>(1000);
useInterval(() => {
setCount((c) => c + 1);
}, delay);
return (
<div>
<p>Contador: {count}</p>
<button onClick={() => setDelay(delay ? null : 1000)}>
{delay ? "Pausar" : "Reanudar"}
</button>
<button onClick={() => setDelay(500)}>Acelerar (500ms)</button>
<button onClick={() => setDelay(2000)}>Desacelerar (2s)</button>
</div>
);
}
// Temporizador de cuenta regresiva
function Timer() {
const { remaining, isRunning, start, pause, reset } = useCountdown({
seconds: 60,
onComplete: () => alert("¡Se acabó el tiempo!"),
});
const minutes = Math.floor(remaining / 60);
const secs = remaining % 60;
return (
<div>
<p style={{ fontSize: 48, fontFamily: "monospace" }}>
{String(minutes).padStart(2, "0")}:{String(secs).padStart(2, "0")}
</p>
<div style={{ display: "flex", gap: 8 }}>
{!isRunning ? (
<button onClick={start}>Iniciar</button>
) : (
<button onClick={pause}>Pausar</button>
)}
<button onClick={reset}>Reiniciar</button>
</div>
</div>
);
}
// Polling de API
function PollingStatus() {
const [status, setStatus] = useState("desconocido");
const [polling, setPolling] = useState(true);
useInterval(
async () => {
try {
const res = await fetch("/api/status");
const data = await res.json();
setStatus(data.status);
if (data.status === "complete") {
setPolling(false); // Detener polling cuando está listo
}
} catch {
setStatus("error");
}
},
polling ? 5000 : null
);
return (
<div>
<p>Estado: {status}</p>
<p>{polling ? "Haciendo polling cada 5s..." : "Polling detenido"}</p>
</div>
);
}Lo que esto demuestra:
- TickCounter: Delay dinámico que se puede cambiar, pausar (
null) o reanudar en tiempo de ejecución - Timer: Cuenta regresiva completa con inicio/pausa/reinicio y callback de finalización
- PollingStatus: Hace polling de una API cada 5 segundos y se detiene cuando el trabajo está completo
Análisis profundo
Cómo funciona
- El problema del cierre obsoleto: Un
setIntervalsimple captura el callback en el momento de la creación. Si el callback hace referencia al estado, ve valores obsoletos. El patrón de Dan Abramov resuelve esto almacenando el callback más reciente en un ref. - Ref para el callback:
savedCallback.currentse actualiza en cada renderizado a través deuseEffect, por lo que el intervalo siempre llama a la versión más reciente del callback. - Efecto separado para el intervalo: El efecto de intervalo solo depende de
delay, por lo que solo se reinicia cuando cambia el delay, no cuando cambia el callback. Esto preserva el timing. - Delay nulo = pausado: Pasar
nullcomo el delay hace que el efecto omitasetIntervalcompletamente, pausando efectivamente el temporizador sin perder el estado. - Limpieza: El efecto devuelve
clearInterval, por lo que cambiar el delay o desmontar siempre limpia el intervalo anterior.
Parámetros y valores de retorno
useInterval
| Parámetro | Tipo | Predeterminado | Descripción |
|---|---|---|---|
callback | () => void | — | Función a llamar en cada tick |
delay | number o null | — | Intervalo en ms, o null para pausar |
useCountdown
| Opción | Tipo | Predeterminado | Descripción |
|---|---|---|---|
seconds | number | — | Valor inicial de la cuenta regresiva |
interval | number | 1000 | Intervalo de tick en ms |
autoStart | boolean | false | Comenzar a contar inmediatamente |
onComplete | () => void | — | Llamado cuando la cuenta regresiva llega a cero |
| Retorno | Tipo | Descripción |
|---|---|---|
remaining | number | Segundos restantes |
isRunning | boolean | Si la cuenta regresiva está activa |
start | () => void | Iniciar o reanudar |
pause | () => void | Pausar |
reset | () => void | Reiniciar a segundos iniciales y detener |
Variaciones
useTimeout: El mismo patrón funciona para setTimeout:
function useTimeout(callback: () => void, delay: number | null): void {
const savedCallback = useRef(callback);
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (delay === null) return;
const id = setTimeout(() => savedCallback.current(), delay);
return () => clearTimeout(id);
}, [delay]);
}Temporizador preciso: setInterval puede desviarse con el tiempo. Para temporizadores precisos, calcula el tiempo transcurrido desde una marca de tiempo inicial:
const startRef = useRef(Date.now());
useInterval(() => {
const elapsed = Math.floor((Date.now() - startRef.current) / 1000);
setRemaining(Math.max(0, totalSeconds - elapsed));
}, 100); // Verificar frecuentemente, calcular desde el reloj de paredNotas de TypeScript
- El tipo de callback es
() => voidya que los callbacks de intervalo típicamente no devuelven valores. delayaceptanumber | nulldondenulles la señal de pausa.- El hook countdown usa interfaces nombradas tanto para las opciones como para el retorno, mejorando el soporte del IDE.
Trampas
- Callbacks asincronos —
setIntervalno espera callbacks asincronos. Si el callback toma más tiempo que el intervalo, las llamadas se superponen. Solución: Usa un flag para omitir ticks mientras una llamada anterior está pendiente, o usasetTimeoutrecursivo en su lugar. - Desvío del temporizador —
setIntervalgarantiza un retraso mínimo, no un timing exacto. Con el tiempo, la desviación se acumula. Solución: Usa la variación de reloj de pared mostrada arriba para precisión. - Throttling en pestañas de fondo — Los navegadores ralentizan los temporizadores en pestañas de fondo a alrededor de 1 segundo mínimo. Solución: Usa
requestAnimationFramepara actualizaciones visuales, o acepta el throttling para polling. - Doble invocación de Strict Mode — React 18 Strict Mode ejecuta los efectos dos veces en desarrollo. Esto crea e inmediatamente limpia un intervalo adicional. Solución: No se requiere acción; esto solo ocurre en desarrollo y la limpieza funciona correctamente.
- Fuga de memoria — Si el componente se desmonta pero
clearIntervalno se llama, el callback continúa disparándose. Solución: La limpieza del efecto maneja esto; nunca usessetIntervalfuera de este patrón hook en React.
Alternativas
| Paquete | Nombre del hook | Notas |
|---|---|---|
usehooks-ts | useInterval, useCountdown | Popular, bien documentado |
ahooks | useInterval, useCountDown | Completo con formato |
@uidotdev/usehooks | useInterval | Mínimo, mismo patrón |
react-use | useInterval | Soporta primer tick inmediato |
react-timer-hook | useTimer, useStopwatch | Componentes temporizador especializados |
Preguntas frecuentes
¿Qué es el problema de cierre obsoleto y cómo lo resuelve useInterval?
- Un
setIntervalsimple captura el callback en el momento de la creación, por lo que ve valores de estado obsoletos. useIntervalalmacena el callback en un ref (savedCallback.current) y lo actualiza en cada renderizado, por lo que el intervalo siempre llama a la versión más reciente.
¿Cómo pausas y reanudas el intervalo?
Pasa null como el delay para pausar y un número para reanudar:
const [delay, setDelay] = useState<number | null>(1000);
useInterval(() => tick(), delay);
// Pausar: setDelay(null)
// Reanudar: setDelay(1000)¿Por qué el efecto de intervalo solo depende del delay y no del callback?
- El callback se almacena en un ref, por lo que los cambios al callback no reinician el intervalo.
- Esto preserva el timing: el intervalo sigue haciendo ticks al mismo ritmo incluso cuando el callback se actualiza.
¿Cómo sabe useCountdown cuándo detenerse?
- Dentro del callback de intervalo, verifica si
remaining <= 1. - Cuando la cuenta regresiva llega a cero, establece
isRunningafalse(que pasanullcomo el delay) y llama aonComplete.
Trampa: ¿Qué sucede si un callback asincronó toma más tiempo que el intervalo?
setIntervalno espera callbacks asincronos, por lo que las llamadas se superpondrán.- Solución: usa un flag para omitir ticks mientras una llamada anterior está pendiente, o usa
setTimeoutrecursivo en su lugar.
Trampa: ¿Por qué setInterval se desvía con el tiempo y cómo lo puedes arreglar?
setIntervalgarantiza un retraso mínimo, no un timing exacto. Con el tiempo, la desviación se acumula.- Solución: calcula el tiempo transcurrido desde una marca de tiempo inicial usando
Date.now()en lugar de contar ticks.
¿Cómo manejan los navegadores los intervalos en pestañas de fondo?
- Los navegadores ralentizan los temporizadores en pestañas de fondo a alrededor de 1 segundo mínimo.
- Para actualizaciones visuales, usa
requestAnimationFrame. Para polling, acepta el throttling.
¿Qué sucede con useInterval en React 18 Strict Mode?
- Strict Mode ejecuta los efectos dos veces en desarrollo, creando e inmediatamente limpiando un intervalo adicional.
- No se requiere acción; la limpieza funciona correctamente y el comportamiento de producción no se ve afectado.
¿Cómo adaptarías este patrón para un retraso único (setTimeout)?
Reemplaza setInterval con setTimeout y clearInterval con clearTimeout:
function useTimeout(callback: () => void, delay: number | null) {
const savedCallback = useRef(callback);
useEffect(() => { savedCallback.current = callback; }, [callback]);
useEffect(() => {
if (delay === null) return;
const id = setTimeout(() => savedCallback.current(), delay);
return () => clearTimeout(id);
}, [delay]);
}¿Cómo expone el hook useCountdown su estado en TypeScript?
- Utiliza interfaces nombradas (
UseCountdownOptionsyUseCountdownReturn) tanto para las opciones como para el valor de retorno. - Esto proporciona autocompletado claro del IDE y documentación para todas las propiedades.
¿Por qué el delay se tipifica como number | null en lugar de number | undefined?
nulles una señal explícita de "pausado" que es fácil de verificar:if (delay === null) return.- Usar
undefinedpodría ser ambiguo con un argumento faltante u olvidado.
¿Puedes cambiar la velocidad del intervalo dinámicamente en tiempo de ejecución?
- Sí. Cuando el valor de
delaycambia, el efecto limpia el intervalo anterior y crea uno nuevo con el delay actualizado. - El ejemplo TickCounter demuestra esto con botones de acelerar y desacelerar.
Relacionado
- useFetch — combina con intervalo para polling
- useDebounce — retraso en lugar de repetición
- useEffect — el mecanismo de limpieza subyacente