logo
Published on

カウントダウンタイマーがずれてしまう問題が発生した件

Authors

実際に起きていた現象

期待していた動作は、カウントダウンタイマーとスマートフォンのタイマーを同時にスタートさせたとき、両方が25分00秒でぴったり同時に終了することでした。しかし実際には、スマートフォンのタイマーがすでに25分00秒で終了しているのに、カウントダウンタイマーはまだ24分30秒を表示しているという状況が発生していました。つまり、タイマーの進みが実時間より遅く、約30秒もの遅延が生じていたのです。

なぜタイマーの進みが遅くなるのか

この「タイマーが遅れる」現象の理由を、JavaScriptのイベントループの仕組みから説明します。

setIntervalで1000ミリ秒を指定したとき、私たちは「正確に1秒ごと」に処理が実行されることを期待します。しかし実際には、setIntervalが保証しているのは「最低でも1000ミリ秒待つ」ということだけなのです。

JavaScriptはシングルスレッドで動作するため、同時に複数の処理を実行できません。すべての処理はイベントループのキューに入れられ、順番に実行されます。setIntervalのコールバックも、このキューに追加されて、自分の順番が来るまで待たされます。

具体的な例を見てみましょう。タイマーのコールバックが実行されるべきタイミングが来たとします。しかしその瞬間、画面のレンダリング処理が走っていたり、APIからのレスポンスを処理していたりすると、タイマーのコールバックは待たされてしまいます。その結果、1000ミリ秒ではなく、1003ミリ秒後、あるいは1005ミリ秒後に実行されることになります。

この「少しずつ遅れる」という現象が、1500回(25分間)繰り返されることで、累積して大きな遅延になります。毎回3ミリ秒遅れるとすると、1500回で4.5秒の遅延です。さらに、時々5ミリ秒や10ミリ秒遅れることもあるため、最終的に30秒という大きな遅延になってしまうのです。

さらに問題を悪化させるのは、ブラウザのバックグラウンドタブに対する処理です。現代のブラウザは、バッテリー消費を抑えるために、ユーザーが見ていないタブのタイマーを意図的に遅延させます。例えば、Chromeではバックグラウンドタブのタイマーは最低でも1秒間隔にまとめられてしまいます。ユーザーが別のタブで作業している間に、カウントダウンタイマーはどんどん遅れていくのです。

重要なのは、setIntervalは「早く実行される」ことはないという点です。イベントループの仕組み上、常に「指定時間以上待つ」ことになるため、必ず遅延方向にずれます。これが、タイマーが想定より長くかかってしまう根本的な理由です。

相対的計算と絶対的計算の違い

修正前のコードでは、以下のような相対的な計算を行っていました。

// 1秒ごとに前回の値から1を引く
setTimeLeft((prevTime) => prevTime - 1)

この方法の問題は、「実際に何秒経過したか」ではなく、「setIntervalが何回実行されたか」を数えているという点です。setIntervalが1003ミリ秒ごとに実行されても、毎回1秒分しか減算しません。つまり、実際には1.003秒経過しているのに、タイマーは1秒しか進まないのです。

実際の時間の流れは、開始から1秒後、2秒後、3秒後と正確に進んでいきます。しかしsetIntervalの実行は、1.003秒後、2.006秒後、3.011秒後というように、少しずつ遅れていきます。
そして毎回の実行で「1秒分」しか減算しないため、実時間とタイマー表示の間にどんどん差が開いていくのです。

修正後の実装では、この問題を根本から解決しました。

// 開始時刻を記録
startTimeRef.current = new Date()

// 毎回、現在時刻から開始時刻を引いて経過時間を計算
const elapsedSeconds = Math.floor((Date.now() - startTimeRef.current) / 1000)
const remainingTime = POMODORO_MINUTES * 60 - elapsedSeconds

この実装では、setIntervalが何回実行されたかは関係ありません。毎回、システム時計を見て「実際に何秒経過したか」を計算し直しています。

たとえsetIntervalが1003ミリ秒後に実行されても、Date.now()で取得する現在時刻は正確です。開始から実際に1.003秒経過していれば、elapsedSecondsは1になります。次の実行が2.006秒後でも、elapsedSecondsは2になります。つまり、setIntervalの実行間隔がどれだけずれても、表示される残り時間は常に正確なのです。

この方法では、setIntervalの役割は「定期的に画面を更新するタイミングを作る」だけになり、時間の計測そのものはシステム時計に委ねられます。システム時計は非常に正確なので、タイマーの精度も劇的に向上します。

ブラウザのバックグラウンド処理による影響

ユーザーが別のタブで作業している間の動作も、この修正で改善されました。

修正前の実装では、ブラウザがタイマーを遅延させると、その遅延がそのまま累積誤差として蓄積されていました。例えば、バックグラウンドでsetIntervalが5秒ごとにしか実行されなくなると、5秒間に5回減算するはずが1回しか減算されず、どんどん遅れていきます。

修正後の実装では、たとえsetIntervalが5秒間実行されなくても、次に実行された時に正確な経過時間が計算されます。5秒後に実行されれば、その時点で「5秒経過した」と正しく認識され、適切な残り時間が表示されます。ユーザーがタブに戻ってきた時、タイマーは正確な時刻を示しているのです。

この違いを理解することが、JavaScriptでタイマーを実装する上での最も重要なポイントです。面接で説明する際も、「setIntervalは実行回数を数えるためのツールではなく、あくまで定期的な処理のトリガーとして使うべきで、正確な時刻はシステム時計で測定する」という原則を説明できると、深い理解を示せるでしょう。

なぜuseRefを使用したら改善されたのか?

まず、ReactにおけるuseStateとuseRefの違いを理解する必要があります。この二つは、どちらも「値を保持する」という点では同じですが、その振る舞いが全く異なります。

useStateは、値が変更されるとコンポーネントの再レンダリングをトリガーします。これは、画面に表示する値を管理するための仕組みです。例えば、timeLeftという状態が1500から1499に変わったら、その変更を画面に反映させるために、Reactはコンポーネント全体を再描画します。

一方、useRefは、値が変更されても再レンダリングをトリガーしません。useRefは「コンポーネントのライフサイクル全体で値を保持し続けるが、その値が変わっても画面の更新は必要ない」というケースのために設計されています。そして重要なのは、再レンダリングが起きても、useRefに保存された値は失われずに保持され続けるということです。

修正前の問題点を振り返る

修正前のコードでは、こんな実装でした。

const [timeLeft, setTimeLeft] = useState(POMODORO_MINUTES * 60)

useEffect(() => {
  let timer
  if (isRunning && timeLeft > 0) {
    timer = setInterval(() => {
      setTimeLeft((prevTime) => prevTime - 1)
    }, 1000)
  }
  return () => clearInterval(timer)
}, [isRunning, timeLeft]) // ← timeLeftが依存配列に含まれている

この実装には重大な問題がありました。動作の流れを追ってみましょう。

タイマーが開始されると、useEffectが実行されてsetIntervalが作成されます。1秒後、setIntervalのコールバックが実行されて、timeLeftが1500から1499に更新されます。ここで何が起きるでしょうか。
timeLeftはuseStateで管理されているため、値の変更がコンポーネントの再レンダリングをトリガーします。そして、再レンダリングが起きると、useEffectは依存配列をチェックします。「timeLeftが変わっている!」と認識し、useEffectが再実行されるのです。

useEffectが再実行されると、まずクリーンアップ関数が呼ばれて古いsetIntervalがクリアされ、次に新しいsetIntervalが作成されます。これが毎秒繰り返されます。つまり、1500秒間で1500回もuseEffectが再実行され、setIntervalが作り直されていたのです。

さらに問題なのは、setIntervalを毎回作り直すことで、微妙なタイミングのずれが生じる可能性があることです。古いsetIntervalをクリアして新しいものを作る、というプロセスの中で、わずかな時間のロスが発生します。これも累積誤差の一因になっていました。

useRefを使った修正後の実装

修正後のコードでは、以下のようにuseRefを使っています。

const timerRef = useRef(null) // setIntervalのIDを保存
const startTimeRef = useRef(null) // 開始時刻を保存

useEffect(() => {
  if (isRunning) {
    if (timerRef.current) clearInterval(timerRef.current)

    timerRef.current = setInterval(() => {
      if (startTimeRef.current) {
        const elapsedSeconds = Math.floor((Date.now() - startTimeRef.current) / 1000)
        const remainingTime = POMODORO_MINUTES * 60 - elapsedSeconds
        setTimeLeft(remainingTime)
      }
    }, 1000)

    return () => clearInterval(timerRef.current)
  }
}, [isRunning]) // ← 依存配列にはisRunningのみ

ここでuseRefが果たしている役割を見ていきましょう。

startTimeRefは、タイマーが開始された瞬間の時刻を保存しています。この値は、タイマーが動いている間、ずっと同じ値を保持し続ける必要があります。そして重要なのは、この値は画面に表示する必要がないということです。開始時刻はあくまで計算のための基準点であり、ユーザーに見せる情報ではありません。

timerRefは、setIntervalが返すタイマーIDを保存しています。これも画面に表示する必要はなく、タイマーを停止するときにclearIntervalに渡すためだけに必要な値です。

この二つの値にuseRefを使うことで、どんな効果があるのでしょうか。

まず、timeLeftが毎秒更新されて再レンダリングが起きても、startTimeRefとtimerRefの値は失われずに保持されます。これは非常に重要です。もしこれらをuseStateで管理していたら、再レンダリングのたびに値が初期化されてしまう可能性があります。

次に、useEffectの依存配列からtimeLeftを除外できるようになりました。なぜなら、useEffect内で直接timeLeftの値を使わなくなったからです。代わりに、startTimeRefを使って毎回経過時間を計算し直しています。依存配列にはisRunningだけが含まれているので、useEffectはタイマーの開始時(isRunningがtrueになった時)と停止時(isRunningがfalseになった時)にのみ実行されます。

なぜこれで改善されたのか

改善の核心は、useEffectの実行回数が劇的に減ったことです。修正前は1500回実行されていたuseEffectが、修正後はタイマーの開始時と停止時の2回だけになりました。

setIntervalは一度だけ作成され、タイマーが停止されるまで同じインスタンスが使われ続けます。これにより、setIntervalの作り直しによる微妙な時間のロスもなくなりました。

そして、毎秒の処理の中で、startTimeRefに保存された開始時刻とDate.now()を使って、実際の経過時間を計算しています。startTimeRefの値は変更されることがないため、常に正確な基準点として機能します。

重要なのは、useRefを使うことで「変更しても再レンダリングをトリガーしない値」と「変更したら再レンダリングをトリガーする値」を適切に分離できたということです。timeLeftは画面に表示する必要があるのでuseStateで管理し、startTimeRefとtimerRefは内部的に使うだけなのでuseRefで管理する、という設計です。

まとめると、useRefによる改善の本質は、「画面に表示する必要のない値を、再レンダリングのサイクルから切り離すことで、useEffectの実行回数を適切にコントロールできた」ということです。そして、Date.now()による実時間ベースの計算と組み合わせることで、正確なタイマーを実現できたのです。