ReactでsetStateした直後のログが古い値になる理由

​​React​flushSync,Playwright,TypeScript,useEffect,useState

React の useState を使ったコンポーネントで、ボタンをクリックしたときに setCount(count + 1) を呼び、その直後に console.log(count) と書くと、押す前の値が出ます。count は、そのレンダーのときに値が決まった定数で、setCount のような set 関数(setState)は、新しい値での再レンダーを React に頼むだけだからです。デバッグのためにログを入れて「state が更新されていない」と迷ったときは、ログを出す場所を変えると、新しい値を確かめられます。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0、Chromium 153 です。.tsx を tsc で型チェックしてから esbuild でまとめ、Playwright で Chromium に読み込ませてボタンを押し、コンソールに出た内容を記録しました。開発用と本番用のビルド、Strict Mode のあり・なしで試しています。クラスコンポーネントの this.setState、useReducer、Firefox と Safari は試していません。

上に setCount(count + 1); と console.log('count:', count); のコード、その下に「+1 する」ボタンと画面の count、下にコンソール欄がある画面。ボタンを3回押すと、画面の count は 1、2、3 と増えるが、コンソールには count: 0、count: 1、count: 2 と、押す前の値が出る。変わった瞬間だけ、画面の数字とコンソールの新しい行がピンクになる
ボタンを3回押したときの、画面の count とコンソールのログ

set の直後のログには、押す前の値が出る

検証に使ったのは、次のコンポーネントです。クリックしたときのほかに、レンダー中(コンポーネントの関数の中)と useEffect の中でもログを出しています。

tsx
import { useEffect, useState } from 'react';

export function LogAfterSet() {
  const [count, setCount] = useState(0);
  console.log('レンダー中の count:', count);
  useEffect(() => {
    console.log('エフェクトの count:', count);
  }, [count]);
  function handleClick() {
    setCount(count + 1);
    console.log('set の直後の count:', count);
  }
  return <button onClick={handleClick}>{count}</button>;
}

Strict Mode なしの開発用ビルドで、最初の表示のあとにボタンを1回押すと、コンソールには次の順に出ました(実際の記録では、ログの先頭に検証用の番号が付いています。この記事のコードとログでは、番号と、ボタンの id を省いています)。

text
set の直後の count: 0
レンダー中の count: 1
エフェクトの count: 1

2回目に押したときは、set の直後の count: 1 のあとに、レンダー中とエフェクトで 2 が出ました。set の直後のログは、いつも押す前の値です。本番用ビルドでも同じでした。

useState の公式リファレンスにある set 関数の注意点には、set 関数は次のレンダーのための state 変数だけを更新し、呼び出したあとに state 変数を読んでも、呼び出す前の画面に出ていた古い値が返る、と書かれています。

count は、React がコンポーネントの関数を呼んだときに useState から受け取った値が入る、ただの const の変数です。handleClick は、そのレンダーで作られた関数なので、中で読む count もそのレンダーの値のままです。React の公式ドキュメントは、これを state がスナップショットのように振る舞うと説明しています。setCount を呼ぶと、React は新しい値でもう一度コンポーネントの関数を呼びます。新しい値が入るのは、その新しいレンダーの count です。

待っても、画面を先に更新しても、count は変わらない

少し待てば新しい値になるのかを確かめるため、await で待ってからログを出すコードも試しました。

tsx
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

export function LogAfterAwait() {
  const [count, setCount] = useState(0);
  async function handleClick() {
    setCount(count + 1);
    await sleep(100);
    const shown = document.getElementById('c2')!.textContent;
    console.log('100ms 待ったあとの count:', count, '画面の表示:', shown);
  }
  return <button id="c2" onClick={handleClick}>{count}</button>;
}

1回目のクリックでは 100ms 待ったあとの count: 0 画面の表示: 1 と出ました。100ミリ秒のあいだに画面は 1 に変わっていますが、count は 0 のままです。2回目も、画面は 2、count は 1 でした。

flushSync を使い、その場で DOM まで更新させた場合も同じです。

tsx
import { flushSync } from 'react-dom';

export function LogAfterFlushSync() {
  const [count, setCount] = useState(0);
  function handleClick() {
    flushSync(() => {
      setCount(count + 1);
    });
    const shown = document.getElementById('c3')!.textContent;
    console.log('flushSync のあとの count:', count, '画面の表示:', shown);
  }
  return <button id="c3" onClick={handleClick}>{count}</button>;
}

1回目のクリックでは flushSync のあとの count: 0 画面の表示: 1 と出ました。画面はもう 1 になっているのに、count は 0 です。

古い値が出るのは、更新が終わっていないからではありません。handleClick が読んでいる count が、押した時点のレンダーの定数だからです。公式ドキュメントの時間経過と state の説明にも、イベントハンドラのコードが非同期であっても、レンダー内の state 変数の値は決して変わらない、とあります。

新しい値をログで確かめる方法

次の値を変数に入れてから set に渡す

同じイベントハンドラの中で新しい値を使いたいときは、先に変数に入れて、その変数を set に渡します。公式リファレンスの、state を更新したのに古い値がログに表示される場合の説明にある書き方です。

tsx
function handleClick() {
  const next = count + 1;
  setCount(next);
  console.log('次の値 next:', next);
}

1回目のクリックで 次の値 next: 1、2回目で 次の値 next: 2 と出て、画面の値と同じになりました。ログを出したあとに、同じ値を API に送ったり、別の計算に使ったりするときも、この next を使います。

再レンダーのあとに見る

画面に出ている値をそのまま確かめたいときは、最初のコードのように、コンポーネントの関数の中か、count を依存配列に入れた useEffect の中でログを出します。どちらも、クリックしたあとの新しいレンダー(エフェクトは、そのあとのコミットのとき)で動くので、新しい値が出ました。useEffect の公式リファレンスのとおり、エフェクトは、依存配列の値が変わったあとのコミット(画面への反映)のときに実行されます。

ただし、ここでの useEffect は値を確かめるためのものです。state が変わったときの処理を書く場所としてエフェクトを使う前に、公式ドキュメントの「そのエフェクトは不要かも」を読んでおくと、イベントハンドラで済む処理を見分けられます。

Strict Mode ありの開発用ビルドでは、同じログが2回ずつ出る場合がありました。

ログを出した場所 最初の表示 クリックしたあと
レンダー中 2回 2回
useEffect の中 2回 1回

Strict Mode の公式リファレンスにある、開発中の二重レンダーの説明のとおり、Strict Mode は開発中にコンポーネントの関数を2回呼びます。エフェクトも、開発中は最初の表示のときにもう一度実行されます。本番用ビルドでは、Strict Mode ありでも1回ずつでした。同じ値が2回並んでいても、更新が2回起きたわけではありません。

更新用関数の中のログは、順番が一定しない

setCount((c) => c + 1) のように関数を渡す書き方(更新用関数)なら、関数の中で新しい値の元になる c をログに出せます。

tsx
function handleClick() {
  setCount((c) => {
    console.log('更新用関数が受け取った c:', c);
    return c + 1;
  });
  console.log('set の直後の count:', count);
}

検証環境では、ログの出る順番がクリックによって違いました。

text
--- 1回目のクリック
更新用関数が受け取った c: 0
set の直後の count: 0
--- 2回目のクリック
set の直後の count: 1
更新用関数が受け取った c: 1

1回目は set の直後のログより先に、2回目はあとに出ています。Strict Mode ありの開発用ビルドでは、更新用関数のログが1回のクリックで2回出ました。これは公式リファレンスにある、更新用関数が2回呼ばれる場合の説明のとおりの動きです。ログの順番や回数が条件によって変わるので、値を確かめるなら、set に渡す前に次の値を入れた変数をログに出すか、再レンダーのあとでログを出すほうが読み取りやすくなります。

ログを出す場所と、出た値

1回目のクリック(count が 0 から 1 に変わるとき)で、それぞれの場所のログに出た値をまとめます。

ログを出した場所 出た値
set の直後 0
set のあと、await で100ミリ秒待ってから 0(画面は 1)
flushSync で更新させたあと 0(画面は 1)
set に渡す前に入れた変数 next 1
コンポーネントの関数の中(レンダー中) 1
count を依存配列に入れた useEffect の中 1
更新用関数の中(受け取った値) 0(この関数が返した 1 が次の値)

イベントハンドラの中で読む count は、どう待っても押した時点の値です。新しい値は、自分で計算した変数か、次のレンダーで確かめます。

​Posted by 86