Reactで、ラップタイムを記録できるストップウォッチを作る方法

​​React​Playwright,TypeScript

ストップウォッチのラップタイムは、押すたびに「前回からの時間」を計算して保存すると、表示の桁に切り捨てたときに、ラップを足しても通算と合わなくなることがあります。押した時点の通算だけを保存し、表示の単位(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、タブをバックグラウンドに回したときの動きは試していません。

「開始」を押すと 00:00.00 から数え始め、「ラップ」を押すたびに表の行が1つ増える。3回ラップを押したあと「停止」を押すと 00:04.90 で止まり、ボタンが「リセット」と「再開」に変わる
開始してから1.2秒、1.0秒、1.5秒の間隔でラップを押し、最後に停止した様子。実際の速さで、ラップを押したときは増えた行を、開始・停止を押したときは通算の表示を、その瞬間だけピンクにしている(カーソル、押した瞬間の輪、クリックの回数は GIF 用に重ねたもの)

経過時間は、動かし始めた時刻との差から出す

動いている間は、経過時間そのものではなく「動かし始めた時刻」を state に持ちます。止めたときに、それまでに計った時間を base に足し込みます。経過時間は、描画のたびに base と、動かし始めた時刻から今までの差を足して出します。

ts
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時間以上になったら、頭に時間を付けます。

ts
// ミリ秒を、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 で表示に直しました(左の列がミリ秒です)。

text
--- 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つ前との差をとってラップにします。

ts
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 ミリ秒の場合で比べました。

text
押した時点の通算(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回押したときも、表示されたラップの合計と、最後のラップの通算は同じでした。

text
$ 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 秒)でラップを押して比べました。

text
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つ以上あり、値に差があるときだけ、最も短いラップと最も長いラップの値を返します。

text
--- lapRange ---
1つだけ      : null
全部同じ     : null
1.20/0.90/1.50: { min: 90, max: 150 }

WCAG の「色の使用」の解説にあるとおり、色の違いだけで区別すると、色が見分けにくい人には伝わりません。そこで、表ではラップのタイムの後ろに「(最速)」「(最遅)」と書き添え、そのうえで文字の色を変えています。同じ値のラップが複数あれば、どれにも同じ表示が付きます。

画面に置く

フックとラップの関数を使うコンポーネントです。動いている間は「ラップ」と「停止」、止まっている間は「リセット」と「再開」を出します。まだ一度も動かしていないときは「開始」と、押せない「リセット」です。表のいちばん上には計っている途中のラップを出し、記録したラップは新しいものから並べます。

tsx
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 のクラスです。

text
開いた直後:
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」でした。止めていた時間は数えられていません。

​Posted by 86