Reactで、残り時間を数えるカウントダウンタイマーを作る方法

​​React​Playwright,TypeScript,アクセシビリティ

カウントダウンタイマーは、setInterval で1秒ごとに残りの秒数から1を引く書き方だと、タイマーの呼び出しが遅れた分だけ表示がずれていきます。終わる時刻を覚えておき、そのつど現在時刻との差から残り時間を出せば、呼び出しが遅れても、次の呼び出しで正しい残り時間に戻ります。

ここでは、開始・一時停止・再開・リセットができる useCountdown フックと、それを使うコンポーネントを作ります。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換しました。タイマーの動きは、Playwright で時計を止め、少しずつ進めて確かめました。1を引く書き方との比べ方だけは、時計を操作せずに実際の時間で動かしています。StrictMode を付けた開発ビルドでも、出力は本番ビルドと同じで、どちらもコンソールにエラーや警告は出ませんでした。Firefox と Safari、タブをバックグラウンドに回したときの動きは試していません。

「開始」をクリックすると 0:10 から 0:09、0:08、0:07 と減る。「一時停止」を押すと 0:07 のまま止まり、「再開」を押すとまた減り始め、0:00 で「時間です」と出て「開始」が押せなくなる
10秒タイマーを開始、一時停止、再開した様子。時計は動いているコマだけ2.5倍速で、表示が変わった瞬間だけピンクにしている。右上の経過時間は一時停止していた2秒も数えている(カーソル、押した瞬間の輪、クリックの回数、経過時間の表示は GIF 用に重ねたもの)

1秒ごとに1を引く書き方は、遅れた分だけずれる

まず、ずれる書き方です。1秒ごとに、残りの秒数から1を引きます。

tsx
import { useEffect, useState } from 'react';

// 比べるための書き方:1秒ごとに残りの秒数から1を引く
export function NaiveCountdown({ seconds: initial, running }: { seconds: number; running: boolean }) {
  const [seconds, setSeconds] = useState(initial);

  useEffect(() => {
    if (!running) return;
    const id = setInterval(() => setSeconds((s) => Math.max(0, s - 1)), 1000);
    return () => clearInterval(id);
  }, [running]);

  return <p className="naive">{seconds}</p>;
}

この書き方は、setInterval が1秒ちょうどに1回ずつ呼ばれることを前提にしています。しかし、MDN の setTimeout の説明にあるとおり、ページやブラウザがほかの処理で忙しいと、タイマーは指定より遅れて呼ばれます。バックグラウンドのタブでは、Chrome はタイマーを1秒に1回、条件によっては1分に1回しか確かめないとも書かれています。

60秒のタイマーを2つ並べ、同じボタンで同時に開始しました。片方は上の1を引く書き方、もう片方はこのあと作る、終わる時刻から計算する書き方です。開始から2.2秒たったところで、ページのメインスレッドを3秒ふさぐ処理を動かしました。

text
開始から 2.2秒: 1を引く: 58秒 / 終わる時刻から計算: 58秒
3秒ふさいだ直後(開始から 5.2秒): 1を引く: 57秒 / 終わる時刻から計算: 55秒
開始から 7.2秒: 1を引く: 55秒 / 終わる時刻から計算: 53秒

ふさいでいた3秒の間に呼ばれるはずだった分は、あとからまとめて呼ばれることはなく、1を引く書き方は2秒遅れたままになりました。7.2秒たったときの本当の残りは52.8秒なので、切り上げて53秒が正しい表示です。

終わる時刻を覚えて、差から残り時間を出す

動いている間は、残り時間ではなく「終わる時刻」を state に持ちます。止まっている間は、そのときの残り時間を持ちます。動いている間の残り時間は、描画のたびに終わる時刻と現在時刻の差で出します。

ts
import { useEffect, useState } from 'react';

type Timer =
  | { status: 'idle' | 'paused' | 'finished'; remaining: number }
  | { status: 'running'; endAt: number };

export function useCountdown(duration: number, tickMs = 250) {
  const [timer, setTimer] = useState<Timer>({ status: 'idle', remaining: duration });
  const [now, setNow] = useState(() => Date.now());

  useEffect(() => {
    if (timer.status !== 'running') return;
    const id = setInterval(() => {
      const t = Date.now();
      if (t >= timer.endAt) setTimer({ status: 'finished', remaining: 0 });
      else setNow(t);
    }, tickMs);
    return () => clearInterval(id);
  }, [timer, tickMs]);

  // 動いている間は、終わる時刻と現在時刻の差が残り時間
  const remaining = timer.status === 'running' ? Math.max(0, timer.endAt - now) : timer.remaining;

  const start = () => {
    if (timer.status === 'running' || timer.status === 'finished') return;
    const t = Date.now();
    setNow(t);
    setTimer({ status: 'running', endAt: t + timer.remaining });
  };
  const pause = () => {
    if (timer.status !== 'running') return;
    setTimer({ status: 'paused', remaining: Math.max(0, timer.endAt - Date.now()) });
  };
  const reset = () => setTimer({ status: 'idle', remaining: duration });

  return { status: timer.status, remaining, start, pause, reset };
}

setInterval のおもな役目は、setNow で現在時刻を入れ直して、描画をやり直させることです。呼び出しが遅れても、次に呼ばれたときの Date.now() で残り時間を出し直すので、表示は正しい値に戻ります。

一時停止と再開も、この形にすると単純です。一時停止では、その時点の残り時間を remaining に入れます。再開(start)では、現在時刻に残り時間を足して、新しい終わる時刻にします。止まっていた時間は、どこにも数えられません。

終わる時刻を過ぎたら、setInterval の中で状態を finished にします。timer が変わるので、useEffect のクリーンアップ関数が clearInterval を呼び、タイマーが止まります。一時停止やリセットで状態が変わったときも同じです。公式リファレンスの、外部のシステムにつなぐ使い方の説明でも、setInterval のタイマーは外部のシステムの例に挙がっていて、クリーンアップ関数で止めることになっています。

秒は切り上げて表示する

残り時間はミリ秒で出てくるので、「分:秒」に直します。このとき、秒は切り捨てではなく切り上げにします。

ts
// 残りのミリ秒を「分:秒」にする。秒は切り上げる
export function formatTime(ms: number): string {
  const total = Math.ceil(ms / 1000);
  const m = Math.floor(total / 60);
  const s = total % 60;
  return `${m}:${String(s).padStart(2, '0')}`;
}

切り上げた場合と切り捨てた場合で、いくつかの値を表示に直しました。

text
残りミリ秒  切り上げ  切り捨て
180000     3:00     3:00
179999     3:00     2:59
179001     3:00     2:59
179000     2:59     2:59
 60000     1:00     1:00
 59999     1:00     0:59
  1000     0:01     0:01
   999     0:01     0:00
     1     0:01     0:00
     0     0:00     0:00

切り捨てると、3分のタイマーを開始した直後(残り179,999ミリ秒)に、もう「2:59」になります。最後の1秒の間は「0:00」と出ているのに、まだ終わっていない状態になります。切り上げれば、開始してから1秒たつまで「3:00」のままで、「0:00」になるのはちょうど終わったときです。

残り時間を確かめる間隔は、1秒より短くする

useCountdown の2つ目の引数 tickMs は、setInterval で残り時間を確かめる間隔です。初期値を1000ミリ秒ではなく250ミリ秒にしたのは、一時停止から再開したあと、表示が変わるのが遅れないようにするためです。

10秒のタイマーを tickMs が250のものと1000のものの2つ用意し、同じボタンで動かしました。開始から2.9秒で一時停止すると、残りは7.1秒で、表示は切り上げて「0:08」です。再開すると、0.1秒後に残りが7秒になるので、そこで「0:07」になるのが正しい表示です。

text
再開から 0.25秒: tickMs=250 の表示が 0:07 に変わった
再開から 1.00秒: tickMs=1000 の表示が 0:07 に変わった
再開から 1.25秒: tickMs=250 の表示が 0:06 に変わった
再開から 2.00秒: tickMs=1000 の表示が 0:06 に変わった

1000ミリ秒ごとだと、再開した時刻から数えて1秒ごとにしか確かめないので、表示はずっと0.9秒遅れたままになりました。250ミリ秒ごとなら、この検証での遅れは0.15秒です。確かめる間隔が250ミリ秒なので、タイマーの呼び出しそのものが遅れない限り、遅れは最大でも250ミリ秒です。その代わり、表示が変わらない間も、setNow を1秒に4回呼ぶことになります。

画面に置く

フックを使うコンポーネントです。動いている間は「一時停止」ボタン、止まっている間は「開始」または「再開」ボタンを出します。終わったら「時間です」と出し、もう一度始めるにはリセットが要るように、「開始」を押せなくしています。

tsx
import { formatTime } from './formatTime';
import { useCountdown } from './useCountdown';

export function Countdown({ duration }: { duration: number }) {
  const { status, remaining, start, pause, reset } = useCountdown(duration);

  return (
    <div>
      <p role="timer" className="time">{formatTime(remaining)}</p>
      {status === 'finished' && <p className="done">時間です</p>}
      {status === 'running' ? (
        <button onClick={pause}>一時停止</button>
      ) : (
        <button onClick={start} disabled={status === 'finished'}>
          {status === 'paused' ? '再開' : '開始'}
        </button>
      )}
      <button onClick={reset}>リセット</button>
    </div>
  );
}

残り時間には role="timer" を付けました。MDN の timer ロールの説明によると、このロールは aria-live が off の扱いになるので、支援技術は1秒ごとの変化を読み上げません。

<Countdown duration={10 * 1000} /> を置いたページを Chromium で開き、Playwright で時計を止めてから50ミリ秒ずつ進めました。3.5秒で一時停止し、5秒待ってから再開して、表示やボタンが変わったときだけ書き出しています。

text
開いた直後       : 0:10 [開始 リセット]
「開始」を押した (0.00秒): 0:10 [一時停止 リセット]
開始から 1.00秒: 0:09 [一時停止 リセット]
開始から 2.00秒: 0:08 [一時停止 リセット]
開始から 3.00秒: 0:07 [一時停止 リセット]
「一時停止」を押した (3.50秒): 0:07 [再開 リセット]
「再開」を押した (8.50秒): 0:07 [一時停止 リセット]
開始から 9.00秒: 0:06 [一時停止 リセット]
開始から 10.00秒: 0:05 [一時停止 リセット]
開始から 11.00秒: 0:04 [一時停止 リセット]
開始から 12.00秒: 0:03 [一時停止 リセット]
開始から 13.00秒: 0:02 [一時停止 リセット]
開始から 14.00秒: 0:01 [一時停止 リセット]
開始から 15.00秒: 0:00 時間です [開始(押せない) リセット]
「リセット」を押した (16.50秒): 0:10 [開始 リセット]

一時停止していた5秒は数えられず、10秒のタイマーは、動いていた時間がちょうど10秒になった開始から15秒の時点で「0:00」になりました。一時停止した時点の残りは6.5秒だったので、再開してから0.5秒後の9.00秒に「0:06」へ変わっています。

​Posted by 86