ReactのuseEffect、知らぬ間に「あれ?」となる原因は「古いクロージャ」かも? – 初心者さんも大丈夫、しっかり解説します!
はじめまして!Web制作・開発の世界へようこそ。Reactって、なんだか魔法みたいでワクワクするけど、時々「あれ?なんか思ってたのと違う動きをするぞ?」って、思わず首をかしげちゃうこと、ありませんか?
特に、画面の表示が変わったり、タイマーが動いたりするような「副作用」を扱う `useEffect` フック。これ、とっても便利なんですが、使い方を間違えると、まるで時間が止まってしまったかのような、古い情報に囚われた動きをしてしまうことがあるんです。
今回は、そんな「あれ?」の原因の多くを占める「古いクロージャ(Stale Closure)問題」について、できるだけ優しく、身近な例え話を交えながら、じっくり解説していきますね。
そもそも「クロージャ」って、一体何者?
「クロージャ」って聞くと、なんだか難しそう…って思うかもしれませんが、大丈夫!これは、JavaScriptの基本的な仕組みの一つなんです。
例えるなら、「関数が、自分が作られた時の「記憶」を、後からでも使えるように持っておける仕組み」 だと思ってください。
例えば、こんなお店のレジを想像してみてください。
function createCounter() {
let count = 0; // この「count」が、関数が作られた時の「記憶」の一部
return function increment() {
count++; // 記憶している「count」を操作する
console.log(count);
};
}
const counter1 = createCounter(); // counter1という「記憶を持った関数」が誕生!
counter1(); // 1
counter1(); // 2
const counter2 = createCounter(); // counter2は、counter1とは別の「記憶を持った関数」
counter2(); // 1
`createCounter` 関数が `increment` 関数を返していますよね。`increment` 関数は、自分が作られた時の `count` の値を覚えています。だから、`counter1` を何度呼び出しても、その `counter1` が覚えている `count` が増えていきます。
`counter2` は `createCounter` で新しく作られたので、`counter1` とは全く別の `count` の値を覚えています。
このように、関数が、その関数が作られた環境(変数など)の値を「記憶」して、後から使えるようにしておくこと。これがクロージャのイメージです。Reactの `useEffect` の中身も、このクロージャの仕組みで動いているんですよ。
`useEffect` と「古いクロージャ」問題の出会い
さて、このクロージャ、便利なのですが、`useEffect` の中でちょっとした落とし穴があります。
特に、「依存配列(Dependency Array)」 の指定を間違えると、この「古いクロージャ」問題が顔を出すんです。
依存配列って、`useEffect` の第二引数に `[変数1, 変数2]` のように書く、あの配列のことです。Reactに「この変数が変わったら、`useEffect` の中身(副作用)をもう一度実行してね」と伝えるための、いわば「変更検知リスト」のようなものです。
ここに、本来監視すべき変数を入れ忘れてしまう と、`useEffect` が一度実行された時の「記憶」をずっと持ち続けてしまう、ということが起こります。
身近な例え:お買い物の「買い物リスト」
例えば、あなたが週末にスーパーへ行くための「買い物リスト」を作るとします。
1. 1回目の買い物リスト作成:
- 「牛乳」「卵」「パン」をリストに入れる。
- このリストを持って、週末に買い物へ行く。
2. 2回目の買い物リスト作成(実は…):
- 新しい週になり、「お米」も買わなきゃ!と思ったとします。
- でも、なぜか、あなたは最初に作った「牛乳」「卵」「パン」のリストをそのまま使い回してしまった。
- そして、「このリストなら、もう新しいお米のことは気にしなくていいはず!」と思って、その古いリストでまた買い物へ行こうとする…。
これだと、新しい「お米」を買うべきなのに、古いリストのままなので「お米」はリストに入っていません。まさに、「古い記憶(リスト)に囚われてしまっている」 状態ですよね。
`useEffect` における古いクロージャ問題も、これと似たようなことが起こるんです。`useEffect` が一度実行されると、その時の変数の値(=クロージャが記憶した値)が固定されてしまいます。依存配列にその変数が含まれていないと、変数が後から更新されても、`useEffect` は「古い値」を使い続けてしまうのです。
具体的なコードで見てみよう!
タイマーを使った例で、この問題を再現してみましょう。
import React, { useState, useEffect } from ‘react’;
function TimerComponent() {
const [count, setCount] = useState(0);
useEffect(() => {
// ここでタイマーを設定しています。
// このuseEffectは、countが変更されても再実行されないように、依存配列が空です。
const intervalId = setInterval(() => {
// setCountは、前回のstateの値に基づいて新しいstateを計算します。
// ここが問題! count は useEffect が作られた時点の「古い」値(常に0)になってしまう。
setCount(prevCount => prevCount + 1); // もし count を直接参照していたら?
// setCount(count + 1); // ← この書き方だと、count が常に 0 になってしまう!
}, 1000);
// クリーンアップ関数:コンポーネントがアンマウントされる時や、
// 副作用が再実行される前に実行される。
return () => {
clearInterval(intervalId); // タイマーをクリア
};
}, []); // <-- 依存配列が空! これが原因で、count の変化を useEffect は見てくれない!
return (
タイマー: {count}
);
}
export default TimerComponent;
このコード、一見すると「1秒ごとに `count` が増えていく」ように見えますよね。でも、実際には…?
「あれ?カウントが全然増えないぞ? ずっと0のままだ!」
という、悲しい結果に。
なぜかというと、`useEffect` は `[]` (空の配列)を依存配列に指定しているため、一度実行されたら二度と中身が再実行されません。つまり、`count` が `0` から `1` に変わろうと、`useEffect` は「あ、僕が作られた時は `count` は `0` だったな。ずっと `0` のままでいいや」と、古い `count` の値を記憶したまま、タイマーが動き続けてしまうんです。
そして、`setCount(count + 1)` のような書き方で `count` を直接参照していると、この `count` は常に `0` なので、結果として `setCount(0 + 1)` が繰り返されるだけで、`count` は `1` にしかならない、あるいは、`setCount(prevCount => prevCount + 1)` のように関数型更新を使わないと、いつまでも `0` を参照し続けてしまうんです。
この、「`useEffect` が一度実行された時の値に囚われてしまう」現象が、「古いクロージャ問題」の正体です。
古いクロージャ問題を解決する、魔法のテクニック!
でも、安心してください!この「古いクロージャ問題」は、Reactでよく使われる、あるテクニックを使えば解決できます。
1. 関数型更新(Functional Updates)を使いこなそう!
`setCount(count + 1)` のような直接的な値の更新ではなく、`setCount(prevCount => prevCount + 1)` のように、「前の状態(`prevCount`)を受け取って、新しい状態を返す」 という書き方をするのが「関数型更新」です。
import React, { useState, useEffect } from ‘react’;
function TimerComponentFixed() {
const [count, setCount] = useState(0);
useEffect(() => {
const intervalId = setInterval(() => {
// ここがポイント!
// setCount に関数を渡すことで、React は常に最新の state を使ってくれる。
// 依存配列に count を指定しなくても、この書き方で正しく動くようになる!
setCount(prevCount => prevCount + 1);
}, 1000);
return () => {
clearInterval(intervalId);
};
}, []); // 依存配列は空のまま!
return (
タイマー: {count}
);
}
export default TimerComponentFixed;
この `setCount(prevCount => prevCount + 1)` という書き方、実はとっても賢いんです。
Reactは `setCount` に関数を渡された場合、「あ、これは前回の状態から新しい状態を計算したいんだな」と理解します。そして、その計算に使う `prevCount` は、常に「その時点で最新の状態」を渡してくれるんです。
つまり、`useEffect` が一度しか実行されなくても、タイマーのコールバック関数の中で `setCount` が呼ばれるたびに、`prevCount` は「直前の `count` の値」になってくれるので、結果として正しくカウントアップされる、というわけです。
これは、依存配列に `count` を追加するのとは、少し違うアプローチですが、タイマーや、状態を更新するだけの副作用では、こちらの方が意図しない再実行を防ぎつつ、正しく動作させられることが多いので、覚えておくと便利ですよ。
2. 依存配列を正しく指定する!
もちろん、一番基本となるのは、`useEffect` の依存配列に、`useEffect` の中で参照しているすべての状態やプロップスを正しく指定する ことです。
先ほどのタイマーの例で、依存配列に `count` を追加してみましょう。
import React, { useState, useEffect } from ‘react’;
function TimerComponentWithDependency() {
const [count, setCount] = useState(0);
useEffect(() => {
// count が変更されるたびに、この useEffect は再実行されます。
const intervalId = setInterval(() => {
// count を直接参照している場合、useEffect が再実行されるので、
// 最新の count の値(例えば 1, 2, 3…)が参照されます。
setCount(count + 1); // ← この書き方でも、useEffect が再実行されるので動く!
}, 1000);
// クリーンアップ関数:useEffect が再実行される前に、前のタイマーをクリアします。
// これがないと、タイマーがどんどん増殖してしまいます!
return () => {
clearInterval(intervalId);
};
}, [count]); // <-- count を依存配列に追加!
return (
タイマー: {count}
);
}
export default TimerComponentWithDependency;
この場合、`count` の値が `0` から `1` に変わると、`useEffect` は「あ、`count` が変わった!」と認識して、一度タイマーをクリアし、新しいタイマーを設定し直します。そして、新しいタイマーの中では、最新の `count` の値(この場合は `1`)を `setCount(1 + 1)` のように参照して、正しくカウントアップが進んでいきます。
ただし! この方法だと、`count` が更新されるたびに `useEffect` が再実行されて、タイマーがクリア&再設定されます。これは、意図した動作であれば問題ありませんが、場合によってはパフォーマンスの低下につながる可能性もあります。
どちらの方法を選ぶべき? – 賢く使い分けよう
- 関数型更新 (`setCount(prevCount => …)`):
- メリット: 依存配列の指定漏れによる古いクロージャ問題を回避しやすく、`useEffect` の不要な再実行を防げる。タイマーのような、状態の「更新」だけが目的の場合に特に有効。
- デメリット: コードが少しだけ長くなる。
- 依存配列の正しい指定 (`useEffect(() => {…}, [state])`):
- メリット: `useEffect` がいつ実行されるべきかが明確になる。Reactのルールに沿った、最も基本的な方法。
- デメリット: 状態が頻繁に更新される場合、`useEffect` の再実行が頻繁になり、パフォーマンスに影響を与える可能性がある。
どちらの方法が良いかは、状況によります。
- 「この副作用は、この値が変わった時だけ実行されれば十分なんだ」 という場合は、依存配列にその値をしっかり指定するのが王道です。
- 「タイマーみたいに、とにかく前の状態から1ずつ増やしたいだけ。毎回タイマーをクリアし直すのはちょっと…」 という場合は、関数型更新を活用するのがスマートです。
Reactの公式ドキュメントにも、「依存配列には、`useEffect` の中で参照しているすべての状態(state)とプロップス(props)をリストアップしてください」と書かれています。まずはこのルールを意識し、もし「なんか毎回再実行されすぎて重いな…」と感じたら、関数型更新や、後述する `useRef` の活用を検討するのが良いでしょう。
補足: `useRef` で「最新の値」をこっそり参照するテクニック
さらに、もう一つ、古いクロージャ問題を回避する、ちょっと応用的なテクニックがあります。それが `useRef` です。
`useRef` は、コンポーネントのレンダリングを跨いで値を保持できる、いわば「箱」のようなものです。この箱の中身は、コンポーネントが再レンダリングされても失われません。
import React, { useState, useEffect, useRef } from ‘react’;
function TimerComponentWithRef() {
const [count, setCount] = useState(0);
// useRefを使って、最新のcountの値を保持するための「箱」を用意
const countRef = useRef(count);
// countが更新されるたびに、countRefの中身も最新の状態に更新する
useEffect(() => {
countRef.current = count;
}, [count]); // countが変わるたびに、refの中身を更新
useEffect(() => {
const intervalId = setInterval(() => {
// ここでsetIntervalのコールバック関数が作られる時、
// countRef.current は常に最新の count の値を参照できる。
// 依存配列には count を指定しないので、useEffect は一度しか実行されない。
console.log(‘現在のcountRefの値:’, countRef.current);
// setCountにも関数型更新を使うのが安全!
setCount(prevCount => prevCount + 1);
}, 1000);
return () => {
clearInterval(intervalId);
};
}, []); // <-- 依存配列は空! useEffect は一度しか実行されない。
return (
タイマー: {count}
);
}
export default TimerComponentWithRef;
この例では、`useEffect` は依存配列が空なので一度しか実行されません。しかし、タイマーのコールバック関数の中で `countRef.current` を参照することで、常に最新の `count` の値(タイマーが作られた時点の古い値ではなく)を取得できます。
`useRef` は、DOM要素への参照だけでなく、このように「レンダリングに影響しない、 mutable(変更可能)な値」を保持するためにも使われることがあります。
ただし、この `useRef` を使う方法は、少し複雑になるので、まずは関数型更新や、依存配列の正しい指定からマスターしていくのがおすすめです。
まとめ: `useEffect` と上手に付き合おう!
`useEffect` の「古いクロージャ問題」、ちょっと複雑に感じたかもしれませんが、
- クロージャは、関数が作られた時の「記憶」を持っておける仕組み。
- 依存配列の指定漏れが、「古い記憶」に囚われる原因になる。
- 解決策は、関数型更新 (`setPrev => …`) を使うか、依存配列を正しく指定すること。
- `useRef` も応用的な解決策として使える。
ということを覚えておけば、きっと大丈夫です。
Reactの学習は、最初は「なんでこうなるの?」と疑問に思うことも多いかもしれませんが、一つ一つ、身近な例えやコードで理解を深めていくことで、必ず「なるほど!」という瞬間が訪れます。
今回解説した「古いクロージャ問題」も、あなたがReactで開発を進めていく上で、きっと何度も遭遇するであろう、でも、解決策を知っていれば怖くない、そんなテーマです。
これからも、このワクワクするReactの世界を、一緒に楽しみながら探求していきましょう!応援しています!

コメント