Skip to the code
cf.

Countdown timer

The tea shop's brewing station needs a timer for each pot. Build Countdown: it takes seconds and counts down from it, one second at a time, starting as soon as it appears.

Show the time left as minutes and seconds, both with two digits, alone in a <p role="timer">. For example, 90 seconds shows 01:30 and 5 seconds shows 00:05.

A button reads Pause while the timer counts and Resume while it's paused. Resume carries on from the time shown.

At 00:00 the timer stops, the button is disabled, and it calls the onDone prop once. onDone is optional. The parent may pass a different onDone function on any render: that must not restart or delay the count, and the one passed last is the one called.

When seconds changes, start again from the new value, counting, even if the timer was paused or had finished. When the timer leaves the page, no timer is left running.

Export Countdown by name. The default export, App, is a small demo for the Preview, and the tests don't use it.

<Countdown seconds={90} onDone={() => alert("Your tea is ready")} />
 
// renders, after 5 seconds:
<p role="timer">01:25</p>
<button>Pause</button>

What the tests check

  1. shows the starting time as mm:ss
    useFakeTimers();
    render(<Countdown seconds={90} />);
    expect(screen.getByRole("timer").textContent).toBe("01:30");
    expect(screen.getByRole("button", { name: "Pause" })).toBeEnabled();
    
  2. counts down one second at a time
    const clock = useFakeTimers();
    render(<Countdown seconds={65} />);
    await clock.advance(1000);
    expect(screen.getByRole("timer").textContent).toBe("01:04");
    await clock.advance(5000);
    expect(screen.getByRole("timer").textContent).toBe("00:59");
    
  3. Pause stops the count and turns into Resume
    const clock = useFakeTimers();
    render(<Countdown seconds={10} />);
    await clock.advance(2000);
    await user.click(screen.getByRole("button", { name: "Pause" }));
    expect(screen.getByRole("button", { name: "Resume" })).toBeInTheDocument();
    await clock.advance(5000);
    expect(screen.getByRole("timer").textContent).toBe("00:08");
    
  4. Resume carries on from where it paused
    const clock = useFakeTimers();
    render(<Countdown seconds={10} />);
    await clock.advance(2000);
    await user.click(screen.getByRole("button", { name: "Pause" }));
    await clock.advance(5000);
    await user.click(screen.getByRole("button", { name: "Resume" }));
    expect(screen.getByRole("button", { name: "Pause" })).toBeInTheDocument();
    await clock.advance(3000);
    expect(screen.getByRole("timer").textContent).toBe("00:05");
    
  5. calls onDone once, at zero and not before
    const clock = useFakeTimers();
    const onDone = spy();
    render(<Countdown seconds={3} onDone={onDone} />);
    await clock.advance(2999);
    expect(onDone.callCount).toBe(0);
    await clock.advance(1);
    expect(screen.getByRole("timer").textContent).toBe("00:00");
    expect(onDone.callCount).toBe(1);
    await clock.advance(5000);
    expect(onDone.callCount).toBe(1);
    
  6. stops at 00:00 with the button disabled
    const clock = useFakeTimers();
    render(<Countdown seconds={2} />);
    await clock.advance(2000);
    expect(screen.getByRole("button", { name: "Pause" })).toBeDisabled();
    expect(clock.pending()).toBe(0);
    await clock.advance(3000);
    expect(screen.getByRole("timer").textContent).toBe("00:00");
    
  7. starts again when seconds changes, even when paused
    const clock = useFakeTimers();
    const { rerender } = render(<Countdown seconds={10} />);
    await clock.advance(3000);
    await user.click(screen.getByRole("button", { name: "Pause" }));
    rerender(<Countdown seconds={20} />);
    expect(screen.getByRole("timer").textContent).toBe("00:20");
    expect(screen.getByRole("button", { name: "Pause" })).toBeInTheDocument();
    await clock.advance(1000);
    expect(screen.getByRole("timer").textContent).toBe("00:19");
    
  8. starts again after finishing, and calls onDone again
    const clock = useFakeTimers();
    const onDone = spy();
    const { rerender } = render(<Countdown seconds={2} onDone={onDone} />);
    await clock.advance(2000);
    expect(onDone.callCount).toBe(1);
    rerender(<Countdown seconds={3} onDone={onDone} />);
    expect(screen.getByRole("timer").textContent).toBe("00:03");
    expect(screen.getByRole("button", { name: "Pause" })).toBeEnabled();
    await clock.advance(3000);
    expect(onDone.callCount).toBe(2);
    
  9. a new onDone doesn't restart the count, and the latest one is called
    const clock = useFakeTimers();
    const first = spy();
    const second = spy();
    const { rerender } = render(<Countdown seconds={3} onDone={first} />);
    await clock.advance(500);
    rerender(<Countdown seconds={3} onDone={second} />);
    await clock.advance(2500);
    expect(screen.getByRole("timer").textContent).toBe("00:00");
    expect(first.callCount).toBe(0);
    expect(second.callCount).toBe(1);
    
  10. leaves no timer running when removed
    const clock = useFakeTimers();
    const { unmount } = render(<Countdown seconds={30} />);
    await clock.advance(1000);
    unmount();
    expect(clock.pending()).toBe(0);
    

Learn more

App.jsxSaved 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).