Reactで、ラップタイムを記録できるストップウォッチを作る方法
ストップウォッチのラップタイムは、押すたびに「前回からの時間」を計算して保存すると、表示の桁に切り捨てたときに、ラップを足しても通算と合わなくなることがあります。押した時点の通算だけを保存し、表示の単位(1/100秒)に切り捨ててから引けば、表に並んだラップの合計は、いつも通算の表示と一致します。
ここでは、開始・停止・再開・ラップ・リセットができる useStopwatch フックと、ラップの表を出すコンポーネントを作ります。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換しました。画面の動きは、Playwright の時計を操作する機能(page.clock)で時計を止め、決まった時刻にボタンを押して確かめました。page.clock は Date のほか、performance.now() と requestAnimationFrame も置き換えます。約0.3秒ごとにラップを8回押した確認だけは、時計を操作せずに実際の時間で動かしています。StrictMode を付けた開発ビルドでも出力は本番ビルドと同じで、どちらもコンソールにエラーや警告は出ませんでした。Firefox と Safari、タブをバックグラウンドに回したときの動きは試していません。

経過時間は、動かし始めた時刻との差から出す
動いている間は、経過時間そのものではなく「動かし始めた時刻」を state に持ちます。止めたときに、それまでに計った時間を base に足し込みます。経過時間は、描画のたびに base と、動かし始めた時刻から今までの差を足して出します。
import { useEffect, useState } from 'react';
type Watch = {
running: boolean;
base: number; // 止めるまでに計った時間の合計(ミリ秒)
startedAt: number; // 動かし始めた時刻(performance.now())
splits: number[]; // ラップを押した時点の通算(ミリ秒)
};
const initial: Watch = { running: false, base: 0, startedAt: 0, splits: [] };
export function useStopwatch() {
const [watch, setWatch] = useState<Watch>(initial);
const [now, setNow] = useState(0);
// 動いている間は、画面を描くたびに現在時刻を入れ直す
useEffect(() => {
if (!watch.running) return;
let id = requestAnimationFrame(function tick() {
setNow(performance.now());
id = requestAnimationFrame(tick);
});
return () => cancelAnimationFrame(id);
}, [watch.running]);
const elapsed = watch.running ? watch.base + Math.max(0, now - watch.startedAt) : watch.base;
const start = () => {
if (watch.running) return;
const t = performance.now();
setNow(t);
setWatch({ ...watch, running: true, startedAt: t });
};
const stop = () => {
if (!watch.running) return;
setWatch({ ...watch, running: false, base: watch.base + performance.now() - watch.startedAt });
};
// 押した瞬間の時刻で通算を出す。now もそろえておく(画面に出ている値は、最大で1コマ分古い)
const lap = () => {
if (!watch.running) return;
const t = performance.now();
setNow(t);
setWatch({ ...watch, splits: [...watch.splits, watch.base + t - watch.startedAt] });
};
const reset = () => {
setWatch(initial);
setNow(0);
};
return { running: watch.running, elapsed, splits: watch.splits, start, stop, lap, reset };
}
時刻には Date.now() ではなく performance.now() を使いました。MDN の performance.now() の説明によると、Date.now() はシステムの時計の調整(時刻合わせなど)の影響を受けることがあります。performance.now() は単調に増える時計にもとづいていて、そうした調整を受けません。また、1ミリ秒より細かい値を返します。
画面を描き直すきっかけには、requestAnimationFrame を使っています。MDN の requestAnimationFrame の説明にあるとおり、コールバックはふつうディスプレイのリフレッシュレートに合わせて呼ばれます。1/100秒の桁を表示するので、setInterval で決まった間隔ごとに描き直すより、画面の更新に合わせて描き直すほうが合っています。コールバックの中では setNow で現在時刻を入れ直すだけで、経過時間は startedAt との差から出し直します。呼ばれる間隔が乱れても、表示される時間はずれません。
停止で watch.running が false になると、useEffect のクリーンアップ関数が cancelAnimationFrame を呼び、描き直しが止まります。公式リファレンスの、外部のシステムにつなぐ使い方の説明にあるとおり、依存する値が変わると、React は前のクリーンアップ関数を呼んでから、新しい値でエフェクトを動かします。
MDN の performance.now() の説明には、OS がスリープしている間も performance.now() は進むことが仕様で求められているのに、実際に進むのは Windows のブラウザだけのようだ、と書かれています。ノートパソコンを閉じている間も計り続けたい場合は、この書き方では、スリープしていた時間が経過時間に入らないおそれがあります。MDN では、長い時間を計るなら Date.now() のほうが向くこともある、としています。この動きは、筆者の検証環境では試していません。
表示は1/100秒の桁に切り捨てる
経過時間はミリ秒の小数で出てくるので、まず1/100秒単位の整数に切り捨て、それを「分:秒.1/100秒」の文字列にします。1時間以上になったら、頭に時間を付けます。
// ミリ秒を、1/100秒(センチ秒)単位の整数に切り捨てる
export const toCentis = (ms: number) => Math.floor(ms / 10);
// 1/100秒の整数を「分:秒.1/100秒」にする。1時間以上なら頭に時間を付ける
export function formatCentis(cs: number): string {
const h = Math.floor(cs / 360000);
const m = Math.floor(cs / 6000) % 60;
const s = Math.floor(cs / 100) % 60;
const c = cs % 100;
const pad = (n: number) => String(n).padStart(2, '0');
const text = `${pad(m)}:${pad(s)}.${pad(c)}`;
return h > 0 ? `${h}:${text}` : text;
}
ストップウォッチでは、まだ経っていない時間を表示しないように、切り上げではなく切り捨てにします。いくつかのミリ秒の値を toCentis で切り捨て、formatCentis で表示に直しました(左の列がミリ秒です)。
--- formatCentis ---
0 00:00.00
9 00:00.00
10 00:00.01
999 00:00.99
59999 00:59.99
60000 01:00.00
3599999 59:59.99
3600000 1:00:00.00
4523456 1:15:23.45
ラップは、押した時点の通算で持つ
ラップの記録には、「前回からの時間」ではなく「押した時点の通算(ミリ秒)」を配列で持ちます(useStopwatch の splits)。表に出すときに、通算を1/100秒に切り捨ててから、1つ前との差をとってラップにします。
import { toCentis } from './time';
export type Lap = { no: number; lap: number; total: number }; // lap と total は 1/100秒
// 押した時点の通算(ミリ秒)の並びから、ラップを出す。
// 先に通算を 1/100秒に切り捨ててから引くので、ラップを足すと表示中の通算と一致する
export function toLaps(splits: number[]): Lap[] {
const totals = splits.map(toCentis);
return totals.map((total, i) => ({ no: i + 1, total, lap: total - (i === 0 ? 0 : totals[i - 1]) }));
}
// 最速と最遅のラップの値。ラップが2つ未満か、全部同じなら null
export function lapRange(laps: Lap[]): { min: number; max: number } | null {
if (laps.length < 2) return null;
const values = laps.map((l) => l.lap);
const min = Math.min(...values);
const max = Math.max(...values);
return min === max ? null : { min, max };
}
順番を逆にして、先にミリ秒で差をとってから切り捨てると、表示が合わなくなります。押した時点の通算が 1005、2004、3003、4002 ミリ秒の場合で比べました。
押した時点の通算(ms): 1005, 2004, 3003, 4002
通算の表示 : 00:04.00
先に引いてから切り捨て: 00:01.00 00:00.99 00:00.99 00:00.99 合計 00:03.97
切り捨ててから引く : 00:01.00 00:01.00 00:01.00 00:01.00 合計 00:04.00
通算の表示は「00:04.00」なのに、先に引いた書き方では、2つ目以降のラップがどれも 999 ミリ秒になって「00:00.99」と切り捨てられ、合計が「00:03.97」になりました。通算の表示も同じ1/100秒単位の切り捨てなので、切り捨ててから引けば、ラップを足した値は最後の通算の表示と必ず一致します。
時計を操作せず、実際の時間で約0.3秒ごとにラップを8回押したときも、表示されたラップの合計と、最後のラップの通算は同じでした。
$ node test/real.mjs
1 | 00:00.35(最速) | 00:00.35
2 | 00:00.37 | 00:00.72
3 | 00:00.37 | 00:01.09
4 | 00:00.37 | 00:01.46
5 | 00:00.38 | 00:01.84
6 | 00:00.38 | 00:02.22
7 | 00:00.40(最遅) | 00:02.62
8 | 00:00.40(最遅) | 00:03.02
表示されたラップの合計(1/100秒): 302 最後のラップの通算(1/100秒): 302
ラップを押したときは、現在時刻もそろえる
lap の中で setNow(t) を呼んでいるのには理由があります。画面に出ている通算は、最後に requestAnimationFrame のコールバックが動いたときの now から出した値なので、ボタンを押した瞬間より少し古くなっています。一方、ラップの通算は、押した瞬間の performance.now() から出します。
now をそろえない場合、表示の通算よりラップの通算のほうが新しくなるため、計っている途中のラップ(通算から最後のラップの通算を引いた値)がマイナスになることがあります。時計を止めたまま、コマの合間の時刻(開始から 1.208 秒と 2.206 秒)でラップを押して比べました。
now をそろえる:
押す直前の表示 00:01.20 → 途中のラップの行: 2 | 00:00.00 | 00:01.20
押す直前の表示 00:02.19 → 途中のラップの行: 3 | 00:00.00 | 00:02.20
now をそろえない:
押す直前の表示 00:01.20 → 途中のラップの行: 2 | 00:00.00 | 00:01.20
押す直前の表示 00:02.19 → 途中のラップの行: 3 | -1:-1.-1 | 00:02.19
now をそろえない書き方では、2回目のラップを押した直後に、表示の通算が「00:02.19」のまま、ラップの通算が「00:02.20」になりました。1/100秒単位の差の −1 を formatCentis に渡したので、途中のラップの行が「-1:-1.-1」と崩れています。そろえれば、表示の通算もラップと同じ「00:02.20」になります。
最速と最遅のラップを目立たせる
lapRange は、ラップが2つ以上あり、値に差があるときだけ、最も短いラップと最も長いラップの値を返します。
--- lapRange ---
1つだけ : null
全部同じ : null
1.20/0.90/1.50: { min: 90, max: 150 }
WCAG の「色の使用」の解説にあるとおり、色の違いだけで区別すると、色が見分けにくい人には伝わりません。そこで、表ではラップのタイムの後ろに「(最速)」「(最遅)」と書き添え、そのうえで文字の色を変えています。同じ値のラップが複数あれば、どれにも同じ表示が付きます。
画面に置く
フックとラップの関数を使うコンポーネントです。動いている間は「ラップ」と「停止」、止まっている間は「リセット」と「再開」を出します。まだ一度も動かしていないときは「開始」と、押せない「リセット」です。表のいちばん上には計っている途中のラップを出し、記録したラップは新しいものから並べます。
import { lapRange, toLaps } from './laps';
import { formatCentis, toCentis } from './time';
import { useStopwatch } from './useStopwatch';
export function Stopwatch() {
const { running, elapsed, splits, start, stop, lap, reset } = useStopwatch();
const total = toCentis(elapsed);
const laps = toLaps(splits);
const range = lapRange(laps);
const current = total - (laps.at(-1)?.total ?? 0); // 計っている途中のラップ
const idle = !running && elapsed === 0;
return (
<div>
<p className="time">{formatCentis(total)}</p>
{running ? (
<>
<button onClick={lap}>ラップ</button>
<button onClick={stop}>停止</button>
</>
) : (
<>
<button onClick={reset} disabled={idle}>リセット</button>
<button onClick={start}>{idle ? '開始' : '再開'}</button>
</>
)}
<table>
<thead>
<tr><th>ラップ</th><th>タイム</th><th>通算</th></tr>
</thead>
<tbody>
{!idle && (
<tr className="current">
<td>{laps.length + 1}</td><td>{formatCentis(current)}</td><td>{formatCentis(total)}</td>
</tr>
)}
{[...laps].reverse().map((l) => {
const mark = l.lap === range?.min ? 'fastest' : l.lap === range?.max ? 'slowest' : undefined;
return (
<tr key={l.no} className={mark}>
<td>{l.no}</td>
<td>{formatCentis(l.lap)}{mark === 'fastest' ? '(最速)' : mark === 'slowest' ? '(最遅)' : ''}</td>
<td>{formatCentis(l.total)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
行の key には、配列の位置ではなくラップの番号(l.no)を使っています。新しいラップは表の上に増えるので、位置を key にすると、ラップを押すたびに既存の行の key がずれます。
<Stopwatch /> を置いたページを Chromium で開き、Playwright で時計を止めてから、決まった時刻にボタンを押しました。開始から 4.000 秒で停止し、3 秒待ってから再開しています。ボタンを押すたびに、通算の表示、ボタン、表の行を書き出しました。行の後ろの <fastest> などは、行に付いている CSS のクラスです。
開いた直後:
00:00.00 [リセット(押せない) 開始]
「開始」を押した(時計で 0.000秒):
00:00.00 [ラップ 停止]
1 | 00:00.00 | 00:00.00 <current>
ラップを押す前(時計で 1.205秒): 00:01.20
「ラップ」を押した(時計で 1.205秒):
00:01.20 [ラップ 停止]
2 | 00:00.00 | 00:01.20 <current>
1 | 00:01.20 | 00:01.20
「ラップ」を押した(時計で 2.204秒):
00:02.20 [ラップ 停止]
3 | 00:00.00 | 00:02.20 <current>
2 | 00:01.00(最速) | 00:02.20 <fastest>
1 | 00:01.20(最遅) | 00:01.20 <slowest>
「ラップ」を押した(時計で 3.703秒):
00:03.70 [ラップ 停止]
4 | 00:00.00 | 00:03.70 <current>
3 | 00:01.50(最遅) | 00:03.70 <slowest>
2 | 00:01.00(最速) | 00:02.20 <fastest>
1 | 00:01.20 | 00:01.20
「停止」を押した(時計で 4.000秒):
00:04.00 [リセット 再開]
4 | 00:00.30 | 00:04.00 <current>
3 | 00:01.50(最遅) | 00:03.70 <slowest>
2 | 00:01.00(最速) | 00:02.20 <fastest>
1 | 00:01.20 | 00:01.20
停止して3秒後: 00:04.00
「再開」を押した(時計で 7.000秒):
00:04.00 [ラップ 停止]
4 | 00:00.30 | 00:04.00 <current>
3 | 00:01.50(最遅) | 00:03.70 <slowest>
2 | 00:01.00(最速) | 00:02.20 <fastest>
1 | 00:01.20 | 00:01.20
「ラップ」を押した(時計で 8.100秒):
00:05.10 [ラップ 停止]
5 | 00:00.00 | 00:05.10 <current>
4 | 00:01.40 | 00:05.10
3 | 00:01.50(最遅) | 00:03.70 <slowest>
2 | 00:01.00(最速) | 00:02.20 <fastest>
1 | 00:01.20 | 00:01.20
「停止」を押した(時計で 8.500秒):
00:05.50 [リセット 再開]
5 | 00:00.40 | 00:05.50 <current>
4 | 00:01.40 | 00:05.10
3 | 00:01.50(最遅) | 00:03.70 <slowest>
2 | 00:01.00(最速) | 00:02.20 <fastest>
1 | 00:01.20 | 00:01.20
「リセット」を押した(時計で 8.500秒):
00:00.00 [リセット(押せない) 開始]
エラーと警告: なし
1.205 秒、2.204 秒、3.703 秒に押したラップは、通算が 1/100 秒に切り捨てられて「00:01.20」「00:02.20」「00:03.70」になり、ラップは 1.20、1.00、1.50 秒です。停止していた3秒の間、通算は「00:04.00」のまま変わらず、再開して 1.1 秒後に押したラップの通算は「00:05.10」でした。止めていた時間は数えられていません。





