こんにちは。フロントエンドの現場で数々の「幽霊タイマー」や「メモリリークの亡霊」と戦ってきたチーフアーキテクトだ。
今日もコードレビューをしていて、`useEffect`の中に無造作に放り込まれた`setInterval`や`setTimeout`が、コンポーネントのアンマウント後も平然と動き続け、すでに消滅したはずのステートを更新してコンソールに無慈悲な警告を吐き散らしているコードを見かけた。
「あぁ、また君か」と思わず天を仰ぐ。
Reactの`useEffect`とタイマー処理の組み合わせは、一見すると非常にシンプルだ。しかし、React 18のConcurrent Renderer、Strict Modeによる二重マウント検証、そしてクロージャが捉える「過去の亡霊(Stale Closure)」の概念まで踏み込むと、ここにはフロントエンドエンジニアの技量がモロに露呈する深淵が広がっている。
今回は、タイマー処理を完全に制御し、メモリ効率とレンダリング負荷の観点から「一歩先を行く」ための実践的なアーキテクチャを語り明かそう。
—
1. なぜタイマーの制御はこれほどまでに泥臭いのか?
JavaScriptの非同期処理モデル、そしてReactの宣言的UIというパラダイム。この2つが交差する地点に、タイマー処理の難しさの本質がある。
`setTimeout`や`setInterval`は、ブラウザのタイマーAPI(WebAPI)にコールバック関数を登録する。もしコンポーネントが画面から消え去った(アンマウントされた)後も、そのタイマーのカウントダウンが生き残っていたらどうなるか?
1. メモリリーク: コールバック関数がクロージャとして保持しているコンポーネント内の巨大な変数やDOMへの参照がGC(ガベージコレクション)に回収されない。
2. 状態更新の暴走 (Memory Warning): アンマウントされたコンポーネントに対して`setState`が走り、Reactからの「Can’t perform a React state update on an unmounted component…」というお馴染みの叱責を受ける。
これらを防ぐための唯一の防壁が、`useEffect`のクリーンアップ関数だ。
—
2. 基本のキ:クリーンアップ関数によるタイマーの破棄
まずは、基本形を確認しておこう。タイマーのIDを保持し、エフェクトが再実行される前、またはコンポーネントが破棄される瞬間に必ず`clearTimeout`(または`clearInterval`)を叩く。
import React, { useState, useEffect } from ‘react’;
export const SafeTimerComponent: React.FC = () => {
const [seconds, setSeconds] = useState
useEffect(() => {
// 1秒ごとにカウントをインクリメントするインターバルを設定
const timerId = setInterval(() => {
// 関数型アップデートを使用し、常に最新のstateを参照する
setSeconds((prevSeconds) => prevSeconds + 1);
}, 1000);
// 【重要】クリーンアップ関数
// 次回のエフェクト実行前、またはコンポーネントのアンマウント時に必ずタイマーをクリアする
return () => {
clearInterval(timerId);
};
}, []); // 依存配列が空なので、マウント時に一度だけ走る
return (
タイマー稼働中: {seconds}秒
);
};
ここで重要なのは、`setSeconds(prev => prev + 1)` と関数型アップデートを使っている点だ。もしここで直接 `setSeconds(seconds + 1)` と書いていたらどうなるか? 次の章で解説する「Stale Closure(古いクロージャ)」の罠にハマることになる。
—
3. Stale Closure(古いクロージャ)の呪縛と戦う
タイマー処理の中で最も厄介なバグの温床が、クロージャによる変数のキャプチャだ。
例えば、「特定のカウントダウン値(例えば`count`)を監視し、その値に応じたロジックを回したい」という要件があったとする。依存配列に`count`を入れてしまうと、`count`が変化するたびにタイマーがクリアされ、再生成されることになる。
// ⚠️ やりがちなアンチパターン
useEffect(() => {
const timerId = setTimeout(() => {
// この時点でクロージャに閉じ込められた count は、
// エフェクトが生成された瞬間の値(古いの値)のまま固定されている可能性がある
console.log(`現在のカウント: ${count}`);
}, 3000);
return () => clearTimeout(timerId);
}, [count]); // countが変わるたびにタイマーが破棄・再作成され、意図した3秒待たずにリセットされる
この問題をエレガントに解決する現代的なアプローチが、`useRef`を用いたミュータブルな値の保持だ。リクエストやタイマーのコールバック内で常に最新の関数や値にアクセスしたい場合、`useRef`は最強の武器になる。
—
4. アーキテクチャの極み:`useInterval` カスタムフックの実装
実務で何度もタイマーを実装していると、「宣言的に`setInterval`を扱いたいのに、リフレッシュや依存配列の管理で頭がおかしくなりそうだ」というフェーズに到達するはずだ。
そこで、Dan Abramovらが提唱したアイデアをベースに、さらにブラッシュアップした「完全版`useInterval`」のカスタムフックを授けよう。このフックは、タイマーをリセットすることなく、常に最新のコールバックを参照し続けることができる。
import { useState, useEffect, useRef } from ‘react’;
// — useInterval カスタムフックの定義 —
function useInterval(callback: () => void, delay: number | null) {
const savedCallback = useRef<() => void>(callback);
// コールバックが更新されるたびに、refのcurrentを最新に差し替える
// これにより、コールバックの変更でタイマーが勝手にリセットされるのを防ぐ
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
// タイマー自体のセットアップ
useEffect(() => {
if (delay === null) {
return; // delayがnullの場合はタイマーを停止する
}
const tick = () => {
savedCallback.current();
};
const id = setInterval(tick, delay);
// クリーンアップで確実に破棄
return () => {
clearInterval(id);
};
}, [delay]); // delayが変更された時のみ、タイマーを再生成する
}
// — 実践的なコンポーネントでの利用例 —
export const AdvancedPoller: React.FC = () => {
const [count, setCount] = useState
const [isPaused, setIsPaused] = useState
// useIntervalを使うことで、複雑な依存配列地獄から解放される
useInterval(
() => {
setCount((prev) => prev + 1);
// ここで最新のステートに基づいたAPIフェッチなどを安全に行える
},
isPaused ? null : 1000 // 一時停止中は delay に null を渡して停止させる
);
return (
ポーリング・システム
カウント: {count}
);
};
このアプローチの美しさは、「タイマーのライフサイクル管理(setInterval/clearInterval)」と「実行するロジック(callback)」の関心を完全に分離している点にある。これにより、タイマーが不要に再生成されることによるパフォーマンス劣化(CPUサイクルやタイマーIDの無駄な払い出し)を完全に防ぐことができる。
—
5. React 18 Strict Mode との向き合い方
開発環境で`useEffect`がなぜか2回走る? React 18のStrict Modeでは、コンポーネントのマウント ➡️ アンマウント ➡️ 再マウント のシミュレーションが意図的に行われる。
この挙動のおかげで、「クリーンアップ関数がちゃんと書けているか」が容赦なくテストされる。もしタイマーのクリア漏れがあれば、Strict Mode下ではコンソールが警告の嵐になるか、2重にタイマーが走るという分かりやすいバグとして顕在化する。
上級エンジニアであれば、Strict Modeを「鬱陶しいお節介」と捉えるのではなく、「自分のコードの堅牢性を証明してくれる自動テスト環境」としてありがたく受け入れるべきだ。
—
まとめ:真に堅牢なフロントエンドのために
タイマー処理の制御は、単に「エラーを出さないため」のものではない。それはユーザーのデバイスのバッテリー寿命を削り、メモリを浪費させないための、開発者としての倫理観の表れでもある。
1. `useEffect`内でのタイマー設定には、必ずクリーンアップ関数で`clearTimeout`/`clearInterval`を記述する。
2. 古いクロージャ(Stale Closure)の罠を避けるため、最新の値の参照には`useRef`を活用する。
3. カスタムフックに処理を抽象化し、関心の分離(ライフサイクル管理 vs 実行ロジック)を徹底する。
このレベルの設計思想がチーム全体に浸透した時、あなたのアプリケーションから「幽霊タイマー」は完全に駆逐され、圧倒的な安定性を手に入れることになるはずだ。
さあ、エディタを開いて、君のコードベースのタイマーたちを見直してみよう。コンソールは静寂に包まれているか?

コメント