FrontendPro

59Implement useInterval() without stale closures

mediumAbout 35 minutesCodingFreereacthookstimersclosures

Problem

This counter looks right but gets stuck at 1:

function Counter() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(() => setCount(count + 1), 1000);
    return () => clearInterval(id);
  }, []); // the callback only ever sees count = 0
  return <h1>{count}</h1>;
}

Implement useInterval(callback, delay) so that:

  • callback runs every delay ms and always sees the latest props and state (no stale closure);
  • passing a new callback on every render does not restart the interval;
  • delay === null pauses the interval, and changing delay restarts it with the new delay;
  • the interval is cleared when the component unmounts.
function Clock() {
  const [count, setCount] = useState(0);
  const [running, setRunning] = useState(true);
  useInterval(() => setCount(count + 1), running ? 1000 : null);
  return <button onClick={() => setRunning(!running)}>{count}</button>;
}

Try it yourself

Write your solution, run it, then submit to check it against the tests.

App.jsxReact + DOM

Step by step

Build the solution one idea at a time.

  1. 1

    Understand the stale closure

    The effect in the example runs once, so the interval keeps the callback from the first render, where count is 0. Every tick sets count to 0 + 1. The callback is "stale": it closed over old state.

  2. 2

    Keep the latest callback in a ref

    Store the callback in savedCallback = useRef(callback) and update it after every render in an effect: savedCallback.current = callback. The ref always points at the newest callback, and updating it does not cause a render.

  3. 3

    Let the interval call through the ref

    The interval runs () => savedCallback.current(), a small function that never changes and always calls the latest callback.

  4. 4

    Restart only when the delay changes

    Set up the interval in an effect that depends on [delay] only. A new callback on every render no longer restarts the timer, which would reset its rhythm.

  5. 5

    Pause with null

    When delay is null, return early without starting an interval. The cleanup of the previous effect already cleared the old one.

Approach

Separate the two things that change: the callback (changes every render, kept in a ref) and the timing (changes rarely, owned by an effect on delay). The interval calls the ref, so it always runs the latest code.

Solution

import { useEffect, useRef, useState } from "react";

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

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

  useEffect(() => {
    if (delay === null) return;
    const id = setInterval(() => savedCallback.current(), delay);
    return () => clearInterval(id);
  }, [delay]);
}

// Preview: a counter you can pause.
function App() {
  const [count, setCount] = useState(0);
  const [running, setRunning] = useState(true);
  useInterval(() => setCount(count + 1), running ? 500 : null);
  return (
    <div>
      <h1 style={{ margin: 0 }}>{count}</h1>
      <button onClick={() => setRunning(!running)}>{running ? "Pause" : "Resume"}</button>
    </div>
  );
}

Complexity

Time: O(1) per render and per tick. Space: O(1).

Edge cases

  • The callback always sees the latest state, even though the interval never restarts.
  • A new callback each render does not reset the interval's rhythm.
  • delay = null pauses; switching back to a number resumes.
  • Changing the delay restarts the interval with the new delay.
  • Unmounting clears the interval.

Follow-up questions

Answer each one out loud first, then reveal the model answer.