React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

intervaltimerpollingcountdownsetIntervalcustom-hook

useInterval — setInterval that works correctly with React state and cleanup

Recipe

Based on Dan Abramov's useInterval pattern, extended with pause/resume and dynamic delay.

import { useEffect, useRef, useCallback, useState } from "react";
 
/**
 * useInterval
 * A declarative setInterval hook. Automatically cleans up on unmount.
 * Pass `null` as delay to pause.
 */
function useInterval(
  callback: () => void,
  delay: number | null
): void {
  const savedCallback = useRef(callback);
 
  // Remember the latest callback without restarting the interval
  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);
 
  useEffect(() => {
    if (delay === null) return;
 
    const id = setInterval(() => savedCallback.current(), delay);
    return () => clearInterval(id);
  }, [delay]);
}
 
/**
 * useCountdown
 * Countdown timer built on useInterval.
 */
interface UseCountdownOptions {
  /** Starting value in seconds */
  seconds: number;
  /** Interval in ms. Default: 1000 */
  interval?: number;
  /** Start immediately. Default: false */
  autoStart?: boolean;
  /** Called when countdown reaches zero */
  onComplete?: () => void;
}
 
interface UseCountdownReturn {
  /** Remaining seconds */
  remaining: number;
  /** Whether the countdown is running */
  isRunning: boolean;
  /** Start or resume the countdown */
  start: () => void;
  /** Pause the countdown */
  pause: () => void;
  /** Reset to the initial value and stop */
  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 };
}

When to reach for this: You need a timer, polling loop, auto-refresh, or countdown that interacts with React state without suffering from stale closure bugs.

Working Example

"use client";
 
// Auto-incrementing counter
function TickCounter() {
  const [count, setCount] = useState(0);
  const [delay, setDelay] = useState<number | null>(1000);
 
  useInterval(() => {
    setCount((c) => c + 1);
  }, delay);
 
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setDelay(delay ? null : 1000)}>
        {delay ? "Pause" : "Resume"}
      </button>
      <button onClick={() => setDelay(500)}>Speed Up (500ms)</button>
      <button onClick={() => setDelay(2000)}>Slow Down (2s)</button>
    </div>
  );
}
 
// Countdown timer
function Timer() {
  const { remaining, isRunning, start, pause, reset } = useCountdown({
    seconds: 60,
    onComplete: () => alert("Time is up!"),
  });
 
  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}>Start</button>
        ) : (
          <button onClick={pause}>Pause</button>
        )}
        <button onClick={reset}>Reset</button>
      </div>
    </div>
  );
}
 
// API polling
function PollingStatus() {
  const [status, setStatus] = useState("unknown");
  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); // Stop polling when done
        }
      } catch {
        setStatus("error");
      }
    },
    polling ? 5000 : null
  );
 
  return (
    <div>
      <p>Status: {status}</p>
      <p>{polling ? "Polling every 5s..." : "Polling stopped"}</p>
    </div>
  );
}

What this demonstrates:

  • TickCounter: Dynamic delay that can be changed, paused (null), or resumed at runtime
  • Timer: Full countdown with start/pause/reset and completion callback
  • PollingStatus: Polls an API every 5 seconds and stops when the job is complete

Deep Dive

How It Works

  • The stale closure problem: A plain setInterval captures the callback at creation time. If the callback references state, it sees stale values. Dan Abramov's pattern solves this by storing the latest callback in a ref.
  • Ref for callback: savedCallback.current is updated every render via useEffect, so the interval always calls the latest version of the callback.
  • Separate effect for interval: The interval effect only depends on delay, so it only restarts when the delay changes, not when the callback changes. This preserves timing.
  • Null delay = paused: Passing null as the delay causes the effect to skip setInterval entirely, effectively pausing the timer without losing state.
  • Cleanup: The effect returns clearInterval, so changing the delay or unmounting always cleans up the previous interval.

Parameters & Return Values

useInterval

ParameterTypeDefaultDescription
callback() => voidFunction to call on each tick
delaynumber or nullInterval in ms, or null to pause

useCountdown

OptionTypeDefaultDescription
secondsnumberStarting countdown value
intervalnumber1000Tick interval in ms
autoStartbooleanfalseStart counting immediately
onComplete() => voidCalled when countdown hits zero
ReturnTypeDescription
remainingnumberSeconds remaining
isRunningbooleanWhether the countdown is active
start() => voidStart or resume
pause() => voidPause
reset() => voidReset to initial seconds and stop

Variations

useTimeout: The same pattern works for 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]);
}

Accurate timer: setInterval can drift over time. For precise timers, calculate elapsed time from a start timestamp:

const startRef = useRef(Date.now());
useInterval(() => {
  const elapsed = Math.floor((Date.now() - startRef.current) / 1000);
  setRemaining(Math.max(0, totalSeconds - elapsed));
}, 100); // Check frequently, calculate from wall clock

TypeScript Notes

  • The callback type is () => void since interval callbacks typically do not return values.
  • delay accepts number | null where null is the pause signal.
  • The countdown hook uses named interfaces for both options and return, improving IDE support.

Gotchas

  • Async callbackssetInterval does not await async callbacks. If the callback takes longer than the interval, calls overlap. Fix: Use a flag to skip ticks while a previous call is pending, or use recursive setTimeout instead.
  • Timer driftsetInterval guarantees minimum delay, not exact timing. Over minutes, drift accumulates. Fix: Use the wall-clock variation shown above for precision.
  • Background tab throttling — Browsers throttle timers in background tabs to around 1 second minimum. Fix: Use requestAnimationFrame for visual updates, or accept the throttling for polling.
  • Strict Mode double-invoke — React 18 Strict Mode runs effects twice in development. This creates and immediately cleans up an extra interval. Fix: No action needed; this only happens in development and the cleanup works correctly.
  • Memory leak — If the component unmounts but clearInterval is not called, the callback continues firing. Fix: The effect cleanup handles this; never use setInterval outside of this hook pattern in React.

Alternatives

PackageHook NameNotes
usehooks-tsuseInterval, useCountdownPopular, well-documented
ahooksuseInterval, useCountDownFull-featured with formatting
@uidotdev/usehooksuseIntervalMinimal, same pattern
react-useuseIntervalSupports immediate first tick
react-timer-hookuseTimer, useStopwatchSpecialized timer components

FAQs

What is the stale closure problem and how does useInterval solve it?
  • A plain setInterval captures the callback at creation time, so it sees stale state values.
  • useInterval stores the callback in a ref (savedCallback.current) and updates it every render, so the interval always calls the latest version.
How do you pause and resume the interval?

Pass null as the delay to pause, and a number to resume:

const [delay, setDelay] = useState<number | null>(1000);
useInterval(() => tick(), delay);
// Pause: setDelay(null)
// Resume: setDelay(1000)
Why does the interval effect only depend on delay and not on the callback?
  • The callback is stored in a ref, so changes to the callback do not restart the interval.
  • This preserves timing: the interval keeps ticking at the same rhythm even when the callback updates.
How does useCountdown know when to stop?
  • Inside the interval callback, it checks if remaining <= 1.
  • When the countdown reaches zero, it sets isRunning to false (which passes null as the delay) and calls onComplete.
Gotcha: What happens if an async callback takes longer than the interval?
  • setInterval does not await async callbacks, so calls will overlap.
  • Fix: use a flag to skip ticks while a previous call is pending, or use recursive setTimeout instead.
Gotcha: Why does setInterval drift over time, and how can you fix it?
  • setInterval guarantees a minimum delay, not exact timing. Over minutes, drift accumulates.
  • Fix: calculate elapsed time from a start timestamp using Date.now() instead of counting ticks.
How do browsers handle intervals in background tabs?
  • Browsers throttle timers in background tabs to around 1 second minimum.
  • For visual updates, use requestAnimationFrame. For polling, accept the throttling.
What happens with useInterval in React 18 Strict Mode?
  • Strict Mode runs effects twice in development, creating and immediately cleaning up an extra interval.
  • No action is needed; cleanup works correctly and production behavior is unaffected.
How would you adapt this pattern for a one-time delay (setTimeout)?

Replace setInterval with setTimeout and clearInterval with 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]);
}
How does the useCountdown hook expose its state in TypeScript?
  • It uses named interfaces (UseCountdownOptions and UseCountdownReturn) for both the options and return value.
  • This provides clear IDE autocompletion and documentation for all properties.
Why is the delay typed as number | null rather than number | undefined?
  • null is an explicit "paused" signal that is easy to check: if (delay === null) return.
  • Using undefined could be ambiguous with a missing or forgotten argument.
Can you change the interval speed dynamically at runtime?
  • Yes. When the delay value changes, the effect cleans up the old interval and creates a new one with the updated delay.
  • The TickCounter example demonstrates this with speed-up and slow-down buttons.