Testing

Testing SetInterval in React Components

I built a live clock component. It updated every second using setInterval. Worked perfectly in the browser. In tests, it was a disaster. Tests hung, leake...

29 Apr 2024

Testing SetInterval in React Components

I built a live clock component. It updated every second using setInterval. Worked perfectly in the browser. In tests, it was a disaster. Tests hung, leaked timers bled into other test cases, and the CI pipeline timed out.

setInterval is harder to test than setTimeout. With setTimeout, you advance time once and you are done. With setInterval, the callback fires repeatedly. You need to control exactly how many ticks happen, and you need to clean up properly.

The component

A simple counter that increments every second:

Tsx
import React, { useState, useEffect } from 'react';

export function LiveCounter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <span data-testid="counter">{count}</span>;
}

Two things matter here. The cleanup function calls clearInterval. And the state update uses the functional form c => c + 1 instead of reading count directly. Without the functional form, the interval always sees the initial value due to the stale closure.

The test

Tsx
import { render, screen, act } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { LiveCounter } from './LiveCounter';

describe('LiveCounter', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  afterEach(() => {
    vi.restoreAllTimers();
  });

  it('starts at zero', () => {
    render(<LiveCounter />);
    expect(screen.getByTestId('counter')).toHaveTextContent('0');
  });

  it('increments after one second', () => {
    render(<LiveCounter />);
    act(() => {
      vi.advanceTimersByTime(1000);
    });
    expect(screen.getByTestId('counter')).toHaveTextContent('1');
  });

  it('increments multiple times', () => {
    render(<LiveCounter />);
    act(() => {
      vi.advanceTimersByTime(5000);
    });
    expect(screen.getByTestId('counter')).toHaveTextContent('5');
  });
});

Why advanceTimersByTime and not runAllTimers

This is the trap. vi.runAllTimers() tries to execute every pending timer. With setInterval, there is always a next tick. It creates an infinite loop and your test hangs forever.

Use advanceTimersByTime(ms) with a specific duration. You control exactly how many intervals fire. Predictable and safe.

Testing cleanup

Components using setInterval must clean up on unmount. Otherwise, you get memory leaks and state updates on unmounted components. Test this explicitly:

Tsx
it('stops the interval on unmount', () => {
  const { unmount } = render(<LiveCounter />);
  unmount();
  act(() => {
    vi.advanceTimersByTime(3000);
  });
  // No error about state updates on unmounted component
});

If the component does not call clearInterval in its cleanup, React throws a warning about state updates on unmounted components. The test catches that.

The trade-off

Fake timers give you full control over setInterval behavior. But your tests now encode assumptions about timing. If you change the interval from 1000ms to 500ms, your test assertions break. This is correct behavior, but it creates coupling between the implementation detail (interval duration) and the test.

One approach: extract the interval duration as a prop or constant, and reference it in both the component and the test.

Keep reading