クロージャーと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
の値を取得できない問題が発生します。
useState
とクロージャーの関係
2. Reactの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
に更新されないために起こります。
useRef
を使った解決策
3. 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
を使わなくても、最新の値を参照しながらカウントを更新できる - シンプルで理解しやすい
useRef
と関数型更新の比較
5. 解決策 | メリット | デメリット |
---|---|---|
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毎に実行し続けてしまう