クロージャーとReactのuseState・useRefについて
1. クロージャーとは?
まず、クロージャー(Closure) とは何かを簡単に説明します。
クロージャーとは、関数が外部のスコープ(関数の外側)にある変数を保持し続ける仕組み のことを指します。JavaScriptでは、関数内で外部スコープの変数を参照すると、その関数が閉じ込めた状態で変数を保持するため、関数の外側で変数が変わっても関数の内側では古い値が使われることがあります。
クロージャーの例
function createCounter() {
let count = 0;
return function () {
count += 1;
console.log(count);
};
}
const counter = createCounter();
counter(); // 1
counter(); // 2
counter(); // 3
ここで createCounter の内部にある count は、外部のスコープにある変数ですが、counter 関数が count を覚えているので、関数を呼び出すたびに count の値が更新され続けます。
しかし、Reactではこのクロージャーが原因で、setTimeout や setInterval 内で最新の useState の値を取得できない問題が発生します。
2. ReactのuseStateとクロージャーの関係
Reactの useState は 非同期で状態を更新 します。そのため、setTimeout や setInterval の中で useState の値を使うと、クロージャーによって古い値が使われる ことがあります。
問題のあるコード(クロージャー問題が発生)
import { useEffect, useState } from "react";
const AutoIncrement = () => {
const [count, setCount] = useState(0);
useEffect(() => {
const intervalId = setInterval(() => {
setCount(count + 1); // 常に最初のcountを使ってしまう
}, 1000);
return () => clearInterval(intervalId);
}, []); // 初回のみ実行
return <div>Count: {count}</div>;
};
このコードでは setCount(count + 1) を実行していますが、setInterval のコールバック内の count は、useEffect が実行されたときの count の値をキャプチャしたまま変わらない ため、カウントが正しく増えません。
これは、setInterval 内の関数が初回レンダリング時の count を記憶してしまい、新しい count に更新されないために起こります。
3. useRefを使った解決策
useRef を使うことで、最新の count の値を正しく取得できます。
useRef を使った修正コード
import { useEffect, useRef, useState } from "react";
const AutoIncrement = () => {
const [count, setCount] = useState(0);
const countRef = useRef(count);
useEffect(() => {
countRef.current = count;
}, [count]);
useEffect(() => {
const intervalId = setInterval(() => {
setCount(countRef.current + 1); // 最新の値を使う
}, 1000);
return () => clearInterval(intervalId);
}, []);
return <div>Count: {count}</div>;
};
なぜ useRef を使うと解決するのか?
-
useRefは コンポーネントが再レンダリングされても値を保持する -
useRefをuseEffectで毎回更新することで、最新のcountをsetInterval内で参照できる -
useRefを変更してもレンダリングが発生しないため、パフォーマンスが向上
4. 関数型更新を使う解決策
もう一つの解決策として、setState の 関数型更新 を使う方法があります。
関数型更新を使った修正コード
import { useEffect, useState } from "react";
const AutoIncrement = () => {
const [count, setCount] = useState(0);
useEffect(() => {
const intervalId = setInterval(() => {
setCount((prevCount) => prevCount + 1); // 最新の値を常に使用
}, 1000);
return () => clearInterval(intervalId);
}, []);
return <div>Count: {count}</div>;
};
関数型更新を使うメリット
-
setCount((prevCount) => prevCount + 1)にすると、prevCountに常に最新の値が入る -
useRefを使わなくても、最新の値を参照しながらカウントを更新できる - シンプルで理解しやすい
5. useRef と関数型更新の比較
| 解決策 | メリット | デメリット |
|---|---|---|
useRef を使う |
最新の状態を直接参照できる、パフォーマンスが良い |
useEffect で useRef を手動で更新する必要がある |
関数型更新 (setState((prev) => prev + 1)) |
シンプルで useRef を使わなくて済む |
setTimeout / setInterval 以外のケースでは useRef ほど柔軟ではない |
6. まとめ
useStateだけだとsetTimeout/setIntervalのクロージャー問題で最新の値を取得できないuseRefを使うと、常に最新の値を参照できる- 関数型更新 (
setState((prev) => prev + 1)) を使うのも有効な解決策
どちらを使うべきかはケースバイケース
-
useRef→ 「現在の値を取得したいだけ」や「頻繁に状態を参照する」場合に適切 - 関数型更新 → 「カウントアップ」など
setStateで更新する場合に適切
Discussion
むしろ AutoIncrement の2回目の実行時に useEffect 内の関数は実行されていないから 感?(保持される関数が初回のものだからもあるが
下記のようなことをお伝えしたかった感じです。
setCount(count + 1);を実行するsetCount(0 + 1);を1000ms毎に実行し続けてしまう