Skip to the code
cf.

useInterval

Write useInterval(callback, delay) and export it from useInterval.js. It calls callback every delay milliseconds while the component is on the page. The tea shop uses it for a brewing timer that counts down.

It always calls the latest callback, the one from the most recent render, so the callback can read the current state and props. But a new callback must not restart the interval. Components pass a new arrow function on every render, and if each render restarted the interval, a component that renders often would keep pushing its next tick back.

A delay of null pauses it: nothing is called, and no timer is left waiting. Going back to a number starts it again. A new delay replaces the interval with one on the new timing. There's never more than one interval, and removing the component clears it.

function BrewTimer() {
  const [seconds, setSeconds] = useState(180);
  useInterval(() => setSeconds(seconds - 1), seconds > 0 ? 1000 : null);
  return <p>{seconds} s left</p>;
}

The Preview shows Demo.jsx, a brewing timer that uses your hook.

What the tests check

  1. calls the callback every delay ms
    const clock = useFakeTimers();
    const tick = spy();
    renderHook(() => useInterval(tick, 100));
    await clock.advance(99);
    expect(tick.callCount).toBe(0);
    await clock.advance(251);
    expect(tick.callCount).toBe(3);
    
  2. calls the latest callback
    const clock = useFakeTimers();
    const first = spy();
    const second = spy();
    const { rerender } = renderHook(({ callback }) => useInterval(callback, 100), { initialProps: { callback: first } });
    rerender({ callback: second });
    await clock.advance(100);
    expect(first.callCount).toBe(0);
    expect(second.callCount).toBe(1);
    
  3. a new callback doesn't restart the interval
    const clock = useFakeTimers();
    const first = spy();
    const second = spy();
    const { rerender } = renderHook(({ callback }) => useInterval(callback, 1000), { initialProps: { callback: first } });
    await clock.advance(600);
    rerender({ callback: second });
    await clock.advance(400);
    expect(second.callCount).toBe(1);
    
  4. a brewing timer counts down from the current state, then stops
    function BrewTimer() {
      const [seconds, setSeconds] = React.useState(3);
      useInterval(() => setSeconds(seconds - 1), seconds > 0 ? 1000 : null);
      return <p>{seconds} s left</p>;
    }
    const clock = useFakeTimers();
    render(<BrewTimer />);
    await clock.advance(1000);
    expect(screen.getByText("2 s left")).toBeInTheDocument();
    await clock.advance(2000);
    expect(screen.getByText("0 s left")).toBeInTheDocument();
    await clock.advance(3000);
    expect(screen.getByText("0 s left")).toBeInTheDocument();
    expect(clock.pending()).toBe(0);
    
  5. a null delay calls nothing and leaves no timer
    const clock = useFakeTimers();
    const tick = spy();
    renderHook(() => useInterval(tick, null));
    expect(clock.pending()).toBe(0);
    await clock.advance(5000);
    expect(tick.callCount).toBe(0);
    
  6. pauses on null and starts again on a number
    const clock = useFakeTimers();
    const tick = spy();
    const { rerender } = renderHook(({ delay }) => useInterval(tick, delay), { initialProps: { delay: 100 } });
    await clock.advance(250);
    expect(tick.callCount).toBe(2);
    rerender({ delay: null });
    await clock.advance(500);
    expect(tick.callCount).toBe(2);
    rerender({ delay: 100 });
    await clock.advance(100);
    expect(tick.callCount).toBe(3);
    
  7. a new delay replaces the interval
    const clock = useFakeTimers();
    const tick = spy();
    const { rerender } = renderHook(({ delay }) => useInterval(tick, delay), { initialProps: { delay: 1000 } });
    await clock.advance(500);
    rerender({ delay: 200 });
    await clock.advance(200);
    expect(tick.callCount).toBe(1);
    expect(clock.pending()).toBe(1);
    await clock.advance(800);
    expect(tick.callCount).toBe(5);
    
  8. renders with new callbacks keep a single interval
    const clock = useFakeTimers();
    const tick = spy();
    const { rerender } = renderHook(() => useInterval(() => tick(), 100));
    rerender();
    rerender();
    expect(clock.pending()).toBe(1);
    await clock.advance(100);
    expect(tick.callCount).toBe(1);
    
  9. removing the component clears the interval
    const clock = useFakeTimers();
    const tick = spy();
    const { unmount } = renderHook(() => useInterval(tick, 100));
    await clock.advance(100);
    unmount();
    expect(clock.pending()).toBe(0);
    await clock.advance(500);
    expect(tick.callCount).toBe(1);
    

Learn more

useInterval.jsSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).