ESC

Type to search the knowledge base.

useInterval Hook

A setInterval that sees the latest callback without restarting the timer when the callback identity changes. Dan Abramov’s ref pattern, named.

intermediate5 min read
  • react
  • hooks
  • timers
  • interview

setInterval in a class component was an instance field. In a function component, the interval callback closes over that render’s props and state. If you put callback in the effect deps, every parent render with an inline () => setCount(c => c + 1) clears and restarts the timer, so a 1000 ms interval never quite stays 1000 ms from the original start. If you omit callback and use [], the interval is stable and stale.

Dan Abramov’s declarative interval solves that with two effects and a ref: keep the latest function in savedCallback, and let the timer effect depend only on delay. The interval id stays stable. The function does not.

delay == null pauses. Cleanup clearInterval. That is the whole hook.

  • Stale closures and reset timers are the two failure modes. A ref fixes the first without causing the second.
  • Effect A: savedCallback.current = callback whenever callback changes. No timer work.
  • Effect B: setInterval(() => savedCallback.current(), delay) when delay is a number. Cleanup clearInterval.
  • delay: null (or undefined) skips scheduling. That is pause, not “run as fast as possible.”
  • Name the pattern. Do not restart the clock because the parent passed a new arrow function.

Keep the interval id stable, swap the callback ref

Naive attempt:

useEffect(() => {
  const id = setInterval(callback, delay);
  return () => clearInterval(id);
}, [callback, delay]);

callback is usually an inline function. Identity changes every render. The effect re-runs. clearInterval + setInterval resets the cadence. A poll that should fire at :00, :01, :02 starts drifting toward “whenever we last rendered.”

Leave callback out:

useEffect(() => {
  const id = setInterval(callback, delay);
  return () => clearInterval(id);
}, [delay]); // exhaustive-deps will yell; callback is stale

The interval is stable. It still sees the callback from the render that last changed delay. count inside that function is frozen. You will log 0 forever.

The ref pattern:

import { useEffect, useRef } from 'react';

function useInterval(callback, delay) {
  const savedCallback = useRef(callback);

  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

  useEffect(() => {
    if (delay == null) return undefined;

    const id = setInterval(() => {
      savedCallback.current();
    }, delay);

    return () => {
      clearInterval(id);
    };
  }, [delay]);
}
function Ticker() {
  const [count, setCount] = useState(0);
  const [delay, setDelay] = useState(1000);

  useInterval(() => {
    setCount((c) => c + 1);
  }, delay);

  return (
    <p>
      {count}
      <button type="button" onClick={() => setDelay(null)}>
        Pause
      </button>
    </p>
  );
}
Piece Role
savedCallback Mutable box, identity-stable, no re-render on write
First effect Publish the latest function after each commit
Second effect Own the timer subscription; deps are [delay] only
delay == null No setInterval, no leftover id

This is the same “latest handler, stable subscription” idea as useEventListener and useRef for Values and DOM. Abramov wrote it up as making setInterval declarative: delay is data, the callback is always current.

You can assign savedCallback.current = callback during render instead of in an effect. That is a mutation during render. It usually works for refs that only store a function, and it publishes the callback before any child effect runs. The two-effect version is the one to draw on a whiteboard unless you want to argue concurrent render semantics. Do not mutate state during render here.

Pause, resume, change delay

useInterval(tick, running ? 5000 : null);

When delay becomes null, the second effect’s cleanup clears the interval. When it becomes 5000 again, a new interval starts. You do not resume mid-tick; you start a new 5000 ms window. Say that.

Changing 5000 to 1000 also restarts. That is correct: the period is a dependency of the subscription.

delay: 0 is legal and aggressive. setInterval(fn, 0) is still a macrotask, not “next line,” and browsers clamp nested timers (The Event Loop).

setInterval vs recursive setTimeout

If callback is async and can run longer than delay, setInterval will queue a second tick while the first is still awaiting. You can overlap fetches. A recursive setTimeout waits for the work (or at least for the call to return) before scheduling the next:

useEffect(() => {
  if (delay == null) return undefined;
  let cancelled = false;
  let id;

  async function loop() {
    await savedCallback.current();
    if (!cancelled) id = setTimeout(loop, delay);
  }

  id = setTimeout(loop, delay);
  return () => {
    cancelled = true;
    clearTimeout(id);
  };
}, [delay]);

Use that for polling dashboards (Polling Metrics Dashboard). Use setInterval when the tick is cheap and you want a steady clock. Abort in-flight fetch on cleanup either way.

Footguns

Missing cleanup. Strict Mode runs setup, cleanup, setup. Two intervals fire twice as fast in development. Production remounts (Fast Refresh, navigate) leak timers and keep calling setState after unmount. Always clearInterval.

Putting count in the interval callback without a functional update. setCount(count + 1) closes over count. Prefer setCount((c) => c + 1) so the ref’d callback does not need count at all.

delay as a string. "1000" may coerce, or it may not mean what you think in a wrapper. Coerce to number or reject.

Tests. Real 1000 ms waits make CI slow. Drive the hook with a fake setTimeout / fake timers. Clear All Timeouts is the “who owns these ids” cousin.

Hooks in a condition. if (enabled) useInterval(fn, 1000) is illegal. Pass enabled ? 1000 : null.

Interview out-loud answer

The hard part is not setInterval. It is getting the latest callback without resetting the timer. I keep the function in a ref, sync that ref in one effect, and start the interval in a second effect that only depends on delay. The tick always calls savedCallback.current(). If delay is null I do not schedule, which is pause. Cleanup clears the interval so Strict Mode and unmount do not leak. I would attribute that split to Dan Abramov’s declarative interval write-up.

Further reading

Related guides