「画面を切り替えたはずなのに、コンソールに謎のエラーが吐き出され続けている…」
「タイマーの速度が、時間が経つにつれてなぜか倍速、3倍速と加速していく…」
フロントエンドの実務開発において、誰もが一度は遭遇するこの現象。原因はほぼ100%、「タイマー(`setTimeout` / `setInterval`)の不適切なクリーンアップ」にあります。
Reactが提供する「宣言的(Declarative)な世界」と、ブラウザが持つ「命令的(Imperative)なWeb APIの世界」。この2つの境界線を正しく繋ぐのが `useEffect` の役割ですが、タイマー処理は最もそのコントロールが難しく、バグの温床になりやすい領域です。
今日は、チームのみんなが二度とタイマーのバグで残業しなくて済むよう、Reactにおけるタイマー制御の極意を、ブラウザの裏側の挙動から実用的なカスタムフックの実装まで、徹底的に解説します。
—
1. なぜタイマーは勝手に消えてくれないのか?(ブラウザの裏側)
まず、根本的な原因を理解しておきましょう。
Reactのコンポーネントは、状態(State)が変わるたびに「ただの関数」として再実行され、仮想DOMを吐き出します。コンポーネントがアンマウント(画面から消滅)すれば、React上の状態や関数はすべてガベージコレクションの対象になります。
しかし、ブラウザのWeb API(`window.setTimeout` や `window.setInterval`)はReactの管理外にあります。
// Reactのライフサイクル外で、ブラウザのタイマーキューに登録されてしまう
useEffect(() => {
setInterval(() => {
console.log(“チクタク…”);
}, 1000);
}, []); // クリーンアップがない!
上のコードを実行すると、ブラウザのタイマーシステムに「1秒ごとにこの関数を実行せよ」というタスクが登録されます。
その後、ユーザーがページを遷移してコンポーネントが画面から消えても、ブラウザはそんな事情を知りません。 メモリ上に残された古いコールバック関数を、律儀に1秒ごとに呼び出し続けます。
これが、コンポーネント消滅後にステートを更新しようとして発生する `Can’t perform a React state update on an unmounted component`(※古いReactバージョンでよく見られた警告)や、メモリリークの正体です。
これを防ぐ唯一の方法が、「コンポーネントが消える時(または次のエフェクトが走る前)に、明示的にタイマーをキャンセルする」ことです。
—
2. 鉄板の基本パターン:`setTimeout` のクリーンアップ
まずは基本となる `setTimeout` の安全な制御パターンです。
ポイントは、`useEffect` が返すクリーンアップ関数(Cleanup Function)で、取得した `timeoutId` を `clearTimeout` することです。
import React, { useState, useEffect } from ‘react’;
export const AutoDismissAlert: React.FC = () => {
const [visible, setVisible] = useState(true);
useEffect(() => {
// 1. タイマーをセットし、識別IDを受け取る
const timerId = setTimeout(() => {
setVisible(false);
console.log(“5秒経ったのでアラートを閉じました”);
}, 5000);
// 2. クリーンアップ関数を返す
// この関数は「コンポーネントのアンマウント時」および「次のエフェクト実行直前」に走る
return () => {
clearTimeout(timerId);
console.log(“タイマーをクリアしました(メモリリーク防止)”);
};
}, []); // 依存配列が空なので、マウント時に1回だけ実行
if (!visible) return null;
return (
);
};
React 18の「開発環境で2回動く」問題への回答
React 18の `StrictMode` では、開発環境において「マウント → アンマウント → 再マウント」というライフサイクルが意図的にシミュレートされます。
上記のコードを開発環境で動かすと、コンソールに瞬時に `タイマーをクリアしました` と表示されますが、これは仕様です。正しくクリーンアップが機能している証拠なので、恐れる必要はありません。
—
3. 中級者を苦しめる「クロージャの罠」と `setInterval`
`setTimeout` は1回走れば終わりですが、定期的に実行し続ける `setInterval` は、さらに厄介な罠を持っています。
それが「Stale Closure(古いクロージャ)問題」です。
以下の「1秒ごとにカウントアップするタイマー」の、間違った実装を見てみましょう。
❌ 失敗例:カウントが `1` から進まないタイマー
// ⚠️ 動かないコードです!
const [count, setCount] = useState(0);
useEffect(() => {
const intervalId = setInterval(() => {
// 依存配列が空なので、この関数が作られた時の count(初期値 0)をずっと参照し続ける
setCount(count + 1);
}, 1000);
return () => clearInterval(intervalId);
}, []); // 依存配列が空
このコードは、画面上では `0` から `1` になったきり、二度と増えません。
なぜなら、`setInterval` に渡したコールバック関数が、初回のレンダリング時に生成された「`count` が `0` のときのクロージャ」で固定されてしまっているからです。毎秒 `0 + 1` が実行され続けている状態です。
❌ 失敗例2:毎秒タイマーを壊して作り直す(超非効率)
じゃあ、依存配列に `count` を入れればいいのでは?
// ⚠️ 動きはするが、パフォーマンスと挙動が最悪なコード
useEffect(() => {
const intervalId = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(intervalId);
}, [count]); // countが変わるたびにタイマーを破棄&再生成
これならカウントは進みます。しかし、`count` が変わるたびに「既存のタイマーを破棄して、新しいタイマーを作る」という処理が走ります。
これは実質的に `setTimeout` を繰り返しているのと変わらず、`setInterval` 本来の「一定間隔で処理を繰り返す」というスレッドの最適化を台無しにしています。重い処理が入った場合、タイマーのラグ(揺らぎ)の原因になります。
⭕️ 解決策:関数型更新(Functional Update)を使う
タイマーを一度も壊さず、常に最新のステートを反映させる最もスマートな方法が、`setCount` に関数を渡す方法です。
import React, { useState, useEffect } from ‘react’;
export const SafeCounter: React.FC = () => {
const [count, setCount] = useState(0);
useEffect(() => {
// タイマーはマウント時に「1度だけ」生成される
const intervalId = setInterval(() => {
// 状態の更新に関数型更新を使うことで、クロージャに依存せず
// 常にReact内部の最新のState(prev)を参照して更新できる
setCount((prev) => prev + 1);
}, 1000);
// アンマウント時に確実にクリア
return () => {
clearInterval(intervalId);
};
}, []); // 依存配列は空でOK!
return (
経過時間: {count} 秒
);
};
—
4. 実務でそのまま使える極上レシピ(カスタムフック化)
実務では、あちこちのコンポーネントで `useEffect` と `clearInterval` のボイラープレートを書くのは非効率ですし、バグの元です。
そこで、React界の重鎮である Dan Abramov 氏が提唱したアプローチをベースに、現代のTypeScript環境で安全に動作する `useInterval` カスタムフック をチームの共通アセットとして定義しましょう。
このカスタムフックの美しさは、「動的に変化する最新のコールバック関数を `useRef` で常に追跡するため、タイマーを一切再起動(リセット)することなく、最新のStateやPropsを参照できる」点にあります。
🌟 決定版 `useInterval` カスタムフック
import { useEffect, useRef } from ‘react’;
/
- 宣言的に setInterval を扱うためのカスタムフック
- @param callback 実行したい関数(最新のStateやPropsを参照可能)
- @param delay 実行間隔(ミリ秒)。null を渡すとタイマーが一時停止します
/
export const useInterval = (callback: () => void, delay: number | null) => {
// 常に最新のコールバック関数を保持するための ref
const savedCallback = useRef<() => void>(callback);
// コールバック関数が更新されたら、ref の中身を最新のものに差し替える
// これにより、タイマーを再起動することなく、最新のクロージャを実行できる
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
// タイマーのセットアップ
useEffect(() => {
// delay が null の場合はタイマーを起動しない(一時停止用)
if (delay === null) return;
const tick = () => {
savedCallback.current();
};
const id = setInterval(tick, delay);
// クリーンアップ処理
return () => clearInterval(id);
}, [delay]); // delay が変わったときだけタイマーを再生成する
};
このカスタムフックの使用例(スッキリ書ける!)
import React, { useState } from ‘react’;
import { useInterval } from ‘./useInterval’; // 上記のフックをインポート
export const AdvancedTimerComponent: React.FC = () => {
const [count, setCount] = useState(0);
const [delay, setDelay] = useState
const [isRunning, setIsRunning] = useState(true);
// useInterval を呼ぶだけ。
// 内部でクリーンアップが自動化されているため、メモリリークの心配はゼロ。
useInterval(
() => {
setCount((prev) => prev + 1);
},
isRunning ? delay : null // isRunning が false になると delay に null が渡り、タイマーが一時停止する
);
return (
- 1. なぜタイマーは勝手に消えてくれないのか?(ブラウザの裏側)
- 2. 鉄板の基本パターン:`setTimeout` のクリーンアップ
- React 18の「開発環境で2回動く」問題への回答
- 3. 中級者を苦しめる「クロージャの罠」と `setInterval`
- ❌ 失敗例:カウントが `1` から進まないタイマー
- ❌ 失敗例2:毎秒タイマーを壊して作り直す(超非効率)
- ⭕️ 解決策:関数型更新(Functional Update)を使う
- 経過時間: {count} 秒
- 4. 実務でそのまま使える極上レシピ(カスタムフック化)
- 🌟 決定版 `useInterval` カスタムフック
- このカスタムフックの使用例(スッキリ書ける!)
- 実用的なタイマーデモ
実用的なタイマーデモ
カウント: {count}
);
};
—
5. シニアからのアドバイス:実装時にこれだけはチェックしよう
最後に、コードレビューで私がよく指摘するチェックリストを共有します。タイマー処理を書いたら、プルリクを出す前にセルフチェックしてみてください。
1. 「クリーンアップ漏れ」はないか?
- `useEffect` の中で `setTimeout` / `setInterval` / `requestAnimationFrame` を使ったら、必ず対応する `clear` 関数を `return` 内で呼んでいるか確認する。
2. 依存配列(deps)を執拗に確認したか?
- 空配列 `[]` にしている場合、その中で使っているStateが「古い値(Stale)」のまま固定されていないか? 関数型更新で解決できないか?
3. そのタイマー、本当に毎回再生成されるべきか?
- レンダリングのたびにタイマーがクリア&セットされ、タイマーが「滑り(期待した時間通りに動かない)」を起こしていないか?
4. 画面遷移(アンマウント)して動かしてみたか?
- 開発コンソールを開き、タイマーが動いているコンポーネントから別の画面に遷移した時、エラーや不必要なログ出力が完全に止まっているか?
まとめ
Reactにおけるタイマーの制御は、「Reactの仮想DOMによる状態管理」と「ブラウザの非同期イベントループ」の橋渡しを理解するための、最高のトレーニンググラウンドです。
クリーンアップをサボると、開発環境では動いているように見えても、本番環境でユーザーがブラウザを立ち上げっぱなしにした時にメモリを食いつぶし、アプリをクラッシュさせる原因になります。
今回紹介した `useInterval` のような「宣言的にタイマーを包む」アプローチを身につけて、堅牢で、メモリに優しく、バグのない美しいReactコードを書いていきましょう!

コメント