やあ。今日も今日とてコンポーネントのライフサイクルと格闘していることだな。
中級へのステップを駆け上がっている君なら、`useEffect`の基本はもうマスターしているはずだ。「画面の描画が終わった後に、API叩いたりサブスクライブしたりするやつね」と。
だがな、実務の現場で本当に差がつくのは、「いかに後始末を綺麗にするか」だ。コードを書くことよりも、コードを片付けることの方が圧倒的に難しい。これは人生でもコードでも同じ真理だ。
今日は、`useEffect`が返すクリーンアップ関数の正体に真っ向から切り込んでいく。ブラウザの裏側の動きから、実務で絶対に踏んではいけない地雷まで、骨の髄まで叩き込んでやるから、心してついてきな。
—
1. クリーンアップ関数とは何か?(なぜ「返す」必要があるのか)
まずは基本のおさらいだ。`useEffect`の第1引数に渡すコールバック関数が、何か別の関数を「return」しているのを見たことがあるだろうか?
useEffect(() => {
// 副作用の処理(例: イベントリスナーの登録)
return () => {
// これがクリーンアップ関数だ!
}, [dependencies]);
Reactはこの「returnされた関数」を、コンポーネントが画面から消える時(アンマウント時)、あるいは依存配列の値が変わり、次の副作用が走る直前に実行する。
なぜわざわざ関数を「返す」設計になっているのか?
それは、「副作用を起こした本人に、その責任を取らせるため」だ。副作用のセットアップ(登録)とティアダウン(解除)を同じスコープ内に閉じ込めることで、「どこで登録したんだっけ?」という迷子を防ぐための、Reactチームの洗練された発明なのさ。
—
2. ブラウザの裏側で何が起きているのか?
「クリーンアップをサボると何がヤバいのか」を理解するには、ブラウザのメモリと非同期処理の世界を覗く必要がある。
例えば、コンポーネント内で`window.addEventListener(‘resize’, …)`を登録したとする。この時、JavaScriptのエンジンは、リスナー関数からコンポーネント内の変数やスコープへの参照(クロージャ)をガッチリと保持し続ける。
もしコンポーネントが画面から消えた(ユーザーが別のページに遷移した)にもかかわらず、`window`オブジェクトは生き残り続けているとしたらどうなるか?
ブラウザは、「もしかしたらまたこの関数が必要になるかもしれない」と親切心から、消えたはずのコンポーネントのメモリをガベージコレクション(GC)の対象外にしてしまうんだ。
これがメモリリークだ。
画面遷移を繰り返すたびにジワジワとメモリが消費され、やがてアプリ全体の動作が重くなり、最終的にブラウザがクラッシュする。実務でこれをやらかすと、インフラコストの無駄遣いか、ユーザーからの冷たい視線を浴びることになる。恐ろしいだろ?
—
3. 実践!実務で使える「綺麗で安全な」コード例
口で言うだけでは説得力がないな。実務でよくある「絶対にクリーンアップが必要な3つのパターン」をまとめたカスタムフックやコンポーネントのコードを用意した。そのままエディタに貼って挙動を確認してくれ。
パターンA: ウィンドウリサイズ監視のクリーンアップ
一番古典的で、かつ一番忘れやすいやつだ。
import React, { useState, useEffect } from ‘react’;
export const WindowWidthViewer = () => {
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
useEffect(() => {
// 1. ハンドラーを定義
const handleResize = () => {
setWindowWidth(window.innerWidth);
};
// 2. イベントリスナーを登録(副作用の発生)
window.addEventListener(‘resize’, handleResize);
console.log(‘リスナーを登録しました’);
// 3. クリーンアップ関数の返却
return () => {
window.removeEventListener(‘resize’, handleResize);
console.log(‘リスナーを綺麗に片付けました(メモリリーク防止)’);
};
}, []); // 初回マウント時のみ実行
return (
現在のウィンドウ幅: {windowWidth}px
);
};
プロの視点:
依存配列が空 `[]` なので、このクリーンアップが走るのはコンポーネントが完全に画面から消える(アンマウント)瞬間だけだ。ここで `removeEventListener` を書き忘れたら、裏で幽霊リスナーが延々と動き続けることになる。
—
パターンB: タイマー(`setInterval`)のクリーンアップ
非同期処理やタイマー系は、クリーンアップしないと「存在しないコンポーネントのstateを更新しようとしてエラー(Can’t perform a React state update on an unmounted component…※React 18以降は警告や無視されることもあるが根本的な解決になっていない)」を引き起こす温床だ。
import React, { useState, useEffect } from ‘react’;
export const CountdownTimer = () => {
const [seconds, setSeconds] = useState(10);
useEffect(() => {
// 1秒ごとにカウントダウンするタイマーを設定
const timerId = setInterval(() => {
setSeconds((prevSeconds) => {
if (prevSeconds <= 1) {
clearInterval(timerId);
return 0;
}
return prevSeconds - 1;
});
}, 1000);
console.log(`タイマー開始: ID ${timerId}`);
// クリーンアップでタイマーを確実にクリアする
return () => {
clearInterval(timerId);
console.log(`タイマー停止: ID ${timerId}`);
};
}, []);
return (
残り時間: {seconds} 秒
);
};
—
パターンC: 【最重要】APIリクエストの競合とアンマウント後の状態更新防止
実務で一番やらかしがちなのが、「APIリクエストを投げた後、レスポンスが返ってくる前にユーザーがページを離れたケース」だ。
最新のReactでは、`AbortController` を使うのがモダンかつスマートなベストプラクティスだ。fetch APIを途中でキャンセルできる。
import React, { useState, useEffect } from ‘react’;
export const UserProfile = ({ userId }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// 1. AbortControllerのインスタンスを生成
const abortController = new AbortController();
const fetchUserData = async () => {
try {
setLoading(true);
const response = await fetch(`https://api.example.com/users/${userId}`, {
// 2. fetchにシグナルを渡してキャンセル可能にする
signal: abortController.signal,
});
const data = await response.json();
setUser(data);
} catch (error) {
// 3. キャンセルエラーの場合は無視し、それ以外のエラーを処理する
if (error.name === ‘AbortError’) {
console.log(‘フェッチは正常にキャンセルされました’);
return;
}
console.error(‘データ取得失敗:’, error);
} finally {
setLoading(false);
}
};
fetchUserData();
// 4. クリーンアップ関数でリクエストを中断する
return () => {
abortController.abort();
};
}, [userId]); // userIdが変わるたびに前のリクエストをキャンセルして新しく叩き直す
if (loading) return
読み込み中…
;
if (!user) return
ユーザーが見つかりません
;
return
;
};
プロの視点:
このコードの美しいところは、`userId` が頻繁に変わるようなUI(例えばタブ切り替えなど)において、「古いリクエストのレスポンスが後から返ってきて、画面の表示がバグる(レースコンディション)」という現象を綺麗に防ぎつつ、不要な通信ネットワークコストをスパッと切り捨てている点だ。これができる中級エンジニアは、チームから圧倒的に信頼される。
—
4. 実行順序の罠:依存値変更時の「お作法」
最後に、クリーンアップ関数が走る「タイミング」についての致命的な勘違いを正しておこう。
多くの人が、「クリーンアップ関数はコンポーネントが消える時だけに走る」と誤解している。違うんだな、これが。
依存配列に値(例: `[userId]`)を指定している場合、`userId` が変更されて次の `useEffect` が再実行される「直前」に、前回のクリーンアップ関数が実行される。
つまり、ライフサイクルの順序はこうだ:
1. 初回マウント時:副作用Aが走る
2. `userId` が変更される
3. 前回の副作用Aに対するクリーンアップAが走る(ここで古いリクエストのキャンセルやイベント解除が行われる)
4. 新しい `userId` を使って副作用Bが走る
この順序を理解していないと、「えっ、なんで古いステートの値がここで参照されるの?」という不可解なバグにハマることになる。クリーンアップは常に「過去の自分を始末する儀式」だと覚えておいてほしい。
—
シニアからのエール
どうだい?`useEffect` のクリーンアップ関数、ただの「おまじない」ではなく、フロントエンドのパフォーマンスと堅牢性を守るための「剣と盾」に見えてきたんじゃないか。
実務でコードレビューをしていると、機能の実装ばかりに目がいって、このクリーンアップがおざなりになっているコードによく出会う。「動けばいいや」ではなく、「美しく終わらせる」までがReactコンポーネントの責任範囲だ。
この知見を武器に、君の書くコードを一段上のレベルに引き上げてくれ。困ったときはいつでも相談に乗るさ。それじゃ、コーディングを楽しんでくれ!

コメント