Reactで、残り時間を数えるカウントダウンタイマーを作る方法
カウントダウンタイマーは、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、タブをバックグラウンドに回したときの動きは試していません。

1秒ごとに1を引く書き方は、遅れた分だけずれる
まず、ずれる書き方です。1秒ごとに、残りの秒数から1を引きます。
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秒ふさぐ処理を動かしました。
開始から 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 に持ちます。止まっている間は、そのときの残り時間を持ちます。動いている間の残り時間は、描画のたびに終わる時刻と現在時刻の差で出します。
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 のタイマーは外部のシステムの例に挙がっていて、クリーンアップ関数で止めることになっています。
秒は切り上げて表示する
残り時間はミリ秒で出てくるので、「分:秒」に直します。このとき、秒は切り捨てではなく切り上げにします。
// 残りのミリ秒を「分:秒」にする。秒は切り上げる
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')}`;
}
切り上げた場合と切り捨てた場合で、いくつかの値を表示に直しました。
残りミリ秒 切り上げ 切り捨て
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」になるのが正しい表示です。
再開から 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回呼ぶことになります。
画面に置く
フックを使うコンポーネントです。動いている間は「一時停止」ボタン、止まっている間は「開始」または「再開」ボタンを出します。終わったら「時間です」と出し、もう一度始めるにはリセットが要るように、「開始」を押せなくしています。
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秒待ってから再開して、表示やボタンが変わったときだけ書き出しています。
開いた直後 : 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」へ変わっています。





