LoginSignup
97
34

ステートの更新が反映されないのはタイミングのせいじゃない ―― 状態のスナップショットとレキシカルスコープ

Last updated at Posted at 2023-05-01

以下のようなコードで、「setCount で count の値を 0 から 1 に更新したのに、console には古い値が表示されてしまう」ということがあると思います。(記事タイトルが不正確なのはわざとで、検索に引っかかって欲しいからです)

const [count, setCount] = useState(0);

const handleClick = () => {
  setCount(1);

  console.log(`同期: ${count}`);
};

「ステート 変更 反映されない」で Google 検索すると「それはステートの変更が非同期に反映されるから」という誤った解説が流布しています。

「setState の反映が非同期である」ことと「古い値を参照してしまう問題」は関係ありません。この記事で訂正できればと思います。

レンダリングで世界が分岐する様子

結論: 公式ドキュメントを見ろ

React 公式の新ドキュメントには、こういうよくある質問への回答が書かれています。まずはそれを読んでみましょう。

トラブルシューティング

state を更新したのに古い値がログに表示される

(中略)
これは、state がスナップショットのように振る舞うためです。state の更新は、新しい state の値での再レンダーをリクエストします。すでに実行中のイベントハンドラ内の count という JavaScript 変数には影響を与えません。

この節で参照されている記事も、参考になるので読みましょう。

JavaScript のコードがどう実行されるのか、あるいはクロージャ周りの知識がないと、

React 公式の意図は分かったけど、JS 的にはどういう仕組みになってるんだ?

と疑問が湧くと思うので、この記事が有益な補足情報になれば幸いです。

実際に console.log を非同期で実行してみる

もし「setState が非同期に反映されることが原因である」が真であるなら、 「setTimeout を使ったときには古い値が表示されるのか新しい値が表示されるのか定まらない」はずですが、実際にはそうなりません。(背理法)

絶対に古い値が表示されます。 以下の CodeSandbox を確認してください。

ソースコードはこちらにも置いておきます。
import { useState } from "react";
import "./styles.css";

export default function App() {
  // *1
  const [count, setCount] = useState(0);

  const handleClick = () => {
    // *2
    // count = 0 のときにクリックしたとき、
    // handleClick 関数内では count の値はずっと 0 のまま
    setCount(1);

    console.log(`同期: ${count}`);
    // ここでは言うまでもなく 0 が表示される

    setTimeout(() => {
      // *3
      console.log(`非同期 (10秒後): ${count}`);
      // タイミングの問題ではないので、必ず 0 が表示される
    }, 10000);
  };

  return (
    <div>
      <div>Count: {count}</div>
      <div>
        <button onClick={handleClick}>setCount(1)</button>
      </div>
    </div>
  );
}

クロージャ(レキシカルスコープ) -- 関数はその外側にある変数をキャプチャする

ソースコード中、関数のはじめに「// handleClick 関数内では count の値はずっと 0 のまま」 と書いた通り、関数はその外側にある変数の値をキャプチャするからです。

handleClick 関数(*2) も、 setTimeout に渡された無名の関数(*3)も、その関数が「作られた場所」・コード上の文脈 (not 関数が「実行される瞬間の状態」) にある count 変数の値を取得しています。 (詳しくは、後の節を参照)

だから、同期に書かれた count の値も、非同期に書かれた count の値も、ともに古い値の 「0」のままになるのです。

詳しく見ていきましょう。

count は const 定数 (再代入不可な変数)

初歩的なところですが、useState のところでは分割代入という機能が使われているので、それを剥がして、count の正体を分かりやすくしましょう。

// const [count, setCount] = useState(0);
const _state_count = useState(0);
const count = _state_count[0];
const setCount = _state_count[1];

この表現からわかるように、 count は let (あるいは var) による変数ではなく、 const を使った定数(変わらない変数) なのがわかります。

なぜ定数なのに状態が変化できるの?

count が定数なのに、状態が変化するのがなぜなのか気になるかもしれません。

その疑問を解消するには、 関数コンポーネントの中に書かれた式がどう扱われるのかを知るのが良いでしょう。

おそらく初心者の方々の直感に反して、 関数コンポーネントの中に書かれた式は 再レンダリングのたびに上から下まで全て実行されます

これは、ちょうど map / forEach に渡した関数や、 for 文の中身 のようなものです。

const arr = [0, 1];

arr.forEach((_value) => { // *1
  // 例: _value = 0 に対して実行されたとき、
  const count = _value;

  // ここでは couont の値が 0
  console.log(`count: ${count}`);
});

// count: 0
// count: 1

count は定数ですが、 arr 配列の各要素 (_value) が代入されるので、 count = 0 の世界」と 「count = 1 の世界」という分離された「世界」たちが関数の中に作り出される ような挙動になります。

React (関数コンポーネント) は let の再代入ではなく、これに似た方式で「コンポーネントの状態」を管理しています。

この「関数を再レンダーのたびに実行する」メンタルモデルを頭に入れたまま次に進みましょう。

クロージャ・レキシカルスコープとは

ここに、 setTimeout を追加すると、どうなるでしょうか?

  const arr = [0, 1];

  arr.forEach((_value) => { // *1
    // 例: _value = 0 に対して実行されたとき、
    const count = _value;

    // ここでは couont の値が 0
    console.log(`count: ${count}`);
  
+   setTimeout(() => { // *2
+     // count はここでも 0 のまま。
+     // _value = 1 のときの count を参照することはない。
+     console.log(`count(lazy): ${count}`);
+   }, 1000);
  });
  // count: 0
  // count: 1
  // count(lazy): 0
  // count(lazy): 1

「遅延して実行されるので新しいほうの値を表示してしまう」のではなく、きちんと

「count: 0」 から1秒後に 「count(lazy): 0」
「count: 1」 から1秒後に 「count(lazy): 1」

と表示されてくれます。表示される内容が予測しやすくて便利ですが、なぜでしょうか?

その秘訣は、 クロージャ(あるいはレキシカルスコープ) の仕組みにあります。 setTimeout に渡された関数内 (*2) から count 変数を参照したとき、

_value = 0 のときには、_value = 0 の世界にある方の count 変数からのみ値を取得し、決して _value = 1 の世界にある count 変数から値を取得することはありません。

MDN にもクロージャについての記事はありますが、けっこう長いので読みづらいです。 個人的には、それよりも Scalaでパーサーを作ってみる〜10:レキシカルスコープとクロージャ | きしだのはてな が端的でわかりやすい「クロージャの説明」だと思っているので読んでみてください。

Scalaでパーサーを作ってみる 〜目次〜 | きしだのはてな シリーズ全体を見れば、プログラミング言語がどのように実行されるのか想像しやすくなると思うので、 Scala という文字列だけを見て避けるのではなく、ぜひ読んでいただきたいです。(パーサーを作るのは大変だけど、 AST を読み取って実行するインタプリタを実装するだけなら、 JS で真似するのも不可能じゃないかも)

handleClick のクロージャ

React に戻って handleClick 関数を見てみましょう。 クロージャを利用した解説を追加しておきます。

export default function App() { // *1
  const [count, setCount] = useState(0);
  
  const handleClick = () => { // *2
    // 外側のスコープから count = 0 をキャプチャ。関数内では変わらない
    setCount(1);
    // -> count = 1 で App 関数を実行するように React 側にリクエストするが、
    // この関数内には影響を及ぼさない。

    console.log(`同期: ${count}`);

    setTimeout(() => { // *3
      // 外側の外側のスコープから count = 0 をキャプチャ。
      // この無名関数内では変わらない
      console.log(`非同期 (10秒後): ${count}`);
    }, 10000);
  };

  // 以下省略
}

JavaScript のクロージャという機構によって、 それぞれの関数から count 定数を参照している ことと、前章で述べた「map 関数の中身のように、レンダリングのたびに関数が実行される」ことを突き合わせてみましょう。

すると、 count の値が handleClick 関数 (*2)、および無名関数 (*3) の中にキャプチャされている様子が想像できると思います。

*2、 *3 どちらにおいても、「値の読み取る元」になるのは"その回"のレンダリングに属する count 変数であり、決して"次の回"のレンダリングに属する count 変数にはならないので、どちらでも「古い値を取って来た」ように見えていたのです。

まとめ

  • 公式ドキュメントを読もう。
  • そして、 JavaScript と仲良くなろう。
  • 「初歩的じゃないけど基本的」は大事。
97
34
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
97
34