【実務・中級編】 タイマー処理(setTimeout/setInterval)の制御 – React実践ガイド

「画面を切り替えたはずなのに、コンソールに謎のエラーが吐き出され続けている…」
「タイマーの速度が、時間が経つにつれてなぜか倍速、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 (

警告:このメッセージは5秒後に自動的に消えます。

);
};

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(1000); // 1秒間隔
const [isRunning, setIsRunning] = useState(true);

// useInterval を呼ぶだけ。
// 内部でクリーンアップが自動化されているため、メモリリークの心配はゼロ。
useInterval(
() => {
setCount((prev) => prev + 1);
},
isRunning ? delay : null // isRunning が false になると delay に null が渡り、タイマーが一時停止する
);

return (

実用的なタイマーデモ

カウント: {count}


);
};

—

5. シニアからのアドバイス:実装時にこれだけはチェックしよう

最後に、コードレビューで私がよく指摘するチェックリストを共有します。タイマー処理を書いたら、プルリクを出す前にセルフチェックしてみてください。

1. 「クリーンアップ漏れ」はないか?

  • `useEffect` の中で `setTimeout` / `setInterval` / `requestAnimationFrame` を使ったら、必ず対応する `clear` 関数を `return` 内で呼んでいるか確認する。

2. 依存配列(deps)を執拗に確認したか?

  • 空配列 `[]` にしている場合、その中で使っているStateが「古い値(Stale)」のまま固定されていないか? 関数型更新で解決できないか?

3. そのタイマー、本当に毎回再生成されるべきか?

  • レンダリングのたびにタイマーがクリア&セットされ、タイマーが「滑り(期待した時間通りに動かない)」を起こしていないか?

4. 画面遷移(アンマウント)して動かしてみたか?

  • 開発コンソールを開き、タイマーが動いているコンポーネントから別の画面に遷移した時、エラーや不必要なログ出力が完全に止まっているか?

まとめ

Reactにおけるタイマーの制御は、「Reactの仮想DOMによる状態管理」と「ブラウザの非同期イベントループ」の橋渡しを理解するための、最高のトレーニンググラウンドです。

クリーンアップをサボると、開発環境では動いているように見えても、本番環境でユーザーがブラウザを立ち上げっぱなしにした時にメモリを食いつぶし、アプリをクラッシュさせる原因になります。

今回紹介した `useInterval` のような「宣言的にタイマーを包む」アプローチを身につけて、堅牢で、メモリに優しく、バグのない美しいReactコードを書いていきましょう!

コメント

タイトルとURLをコピーしました