【入門編】 useEffectにおける古いクロージャ(Stale Closure)問題 – React実践ガイド

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の世界を、一緒に楽しみながら探求していきましょう!応援しています!

コメント

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