🫠

クロージャーとReactのuseState・useRefについて

2025/03/12に公開2

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ではこのクロージャーが原因で、setTimeoutsetInterval 内で最新の useState の値を取得できない問題が発生します。

2. ReactのuseStateとクロージャーの関係

Reactの useState非同期で状態を更新 します。そのため、setTimeoutsetInterval の中で 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コンポーネントが再レンダリングされても値を保持する
  • useRefuseEffect で毎回更新することで、最新の countsetInterval 内で参照できる
  • 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 を使う 最新の状態を直接参照できる、パフォーマンスが良い useEffectuseRef を手動で更新する必要がある
関数型更新 (setState((prev) => prev + 1)) シンプルで useRef を使わなくて済む setTimeout / setInterval 以外のケースでは useRef ほど柔軟ではない

6. まとめ

  • useState だけだと setTimeout / setInterval のクロージャー問題で最新の値を取得できない
  • useRef を使うと、常に最新の値を参照できる
  • 関数型更新 (setState((prev) => prev + 1)) を使うのも有効な解決策

どちらを使うべきかはケースバイケース

  • useRef → 「現在の値を取得したいだけ」や「頻繁に状態を参照する」場合に適切
  • 関数型更新 → 「カウントアップ」など setState で更新する場合に適切

Discussion

junerjuner

これは、setInterval 内の関数が初回レンダリング時の count を記憶してしまい、新しい count に更新されないために起こります。

むしろ AutoIncrement の2回目の実行時に useEffect 内の関数は実行されていないから 感?(保持される関数が初回のものだからもあるが

工兵工兵

下記のようなことをお伝えしたかった感じです。

  • setIntervalが実行されているので、1000ms毎に setCount(count + 1); を実行する
  • setInterval内ではcountの値はずっと0のままで更新されないので setCount(0 + 1); を1000ms毎に実行し続けてしまう