1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Reactのstate更新でハマる前に理解したい:stateはスナップショットである

1
Posted at

Reactでstateを扱っていると、次のようなコードを書いたことがあるかもしれません。

setCount(count + 1);
setCount(count + 1);
setCount(count + 1);

直感的にはcount3増えるように見えます。
しかし、実際には1しか増えないことがあります。

また、次のようなコードでも混乱することがあります。

setCount(count + 1);
console.log(count);

setCountを呼んだ直後なのにconsole.logには更新前の値が表示される。

これはReactのstate更新が壊れているわけではありません。
Reactのstateは通常のJavaScript変数とは違うモデルで動いているからです。

この記事では、Reactのstate更新でよくある誤解を避けるために次の4つを整理します。

  • stateはスナップショットである
  • state更新は即時反映ではなく、次のレンダーを予約する
  • Reactは複数のstate更新をbatchingする
  • 前回のstateに基づいて更新する場合はfunctional updaterを使う

stateは普通の変数ではない

まず、次のコードを見てみます。

import { useState } from "react";

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

  function handleClick() {
    setCount(count + 1);
    console.log(count);
  }

  return (
    <button onClick={handleClick}>
      count: {count}
    </button>
  );
}

ボタンをクリックすると、画面上の count は更新されます。
しかし、console.log(count)には更新前の値が表示されます。

これは、setCountが現在のcount変数を直接書き換えているわけではないからです。

Reactにおけるstate更新は、現在実行中の関数内の変数を変更する処理ではありません。
次回のレンダーで使うstateをReactに伝え、再レンダーを予約する処理です。

つまり、次のように考えると理解しやすくなります。

setCount(count + 1)
= 今この場でcountを書き換える
ではない

setCount(count + 1)
= 次のレンダーではcountをこの値にしてほしいとReactに伝える

この違いを理解していないとstate更新後にすぐ最新値を読もうとしてハマります。

レンダーごとにstateは固定される

Reactコンポーネントはレンダーされるたびにその時点のstateを受け取ります。

たとえば、count0のときにレンダーされたコンポーネントでは、そのレンダー内のcountはずっと0です。

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

  function handleClick() {
    setCount(count + 1);
    alert(count);
  }

  return (
    <button onClick={handleClick}>
      count: {count}
    </button>
  );
}

このとき、handleClickの中でsetCount(count + 1)を呼んでも、その関数内のcountは変わりません。

なぜならhandleClickは「そのレンダー時点の count」を参照しているからです。

Reactでは各レンダーがそれぞれ独自のstateスナップショットを持っていると考えるとわかりやすいです。

1回目のレンダー:
count = 0
handleClickは count = 0 を見る

2回目のレンダー:
count = 1
新しいhandleClickは count = 1 を見る

このようにイベントハンドラもレンダー時点のstateを閉じ込めています。

setStateを3回呼んでも3増えるとは限らない

次のコードを見てみます。

import { useState } from "react";

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

  function handleClick() {
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
  }

  return (
    <button onClick={handleClick}>
      count: {count}
    </button>
  );
}

setCount(count + 1)を3回呼んでいるのでcountは3増えそうに見えます。

しかし、count0のレンダーでは、handleClickの中のcountは最後まで0のままです。

つまり、Reactには次のような更新が渡されています。

setCount(0 + 1);
setCount(0 + 1);
setCount(0 + 1);

結果として次のstateは1になります。

これはReactが間違っているのではなく、すべての更新が同じレンダーのcountを参照しているためです。

Reactはstate更新をbatchingする

もう一つ重要なのがbatchingです。

Reactはイベントハンドラの中で複数のstate更新が発生した場合、それらをすぐに1つずつ反映するのではなく、まとめて処理します。

function handleClick() {
  setCount(count + 1);
  setIsOpen(true);
  setMessage("updated");
}

このようなコードでReactはそれぞれの setState ごとに即座に再レンダーするわけではありません。
イベントハンドラの処理が終わったあと、必要な更新をまとめて処理します。

この仕組みによって不要な再レンダーを減らすことができます。

ただし、batchingされるからこそ次のような誤解が生まれます。

setCount(count + 1);
console.log(count);

setCountを呼んだ直後でも現在のcountが変わっているわけではありません。
Reactは次のレンダーに向けて更新を予約しているだけです。

前回のstateに基づく更新にはfunctional updaterを使う

では、countを確実に3増やしたい場合はどうすればよいのでしょうか。

この場合は、現在のレンダー内のcountを直接使うのではなく、functional updaterを使います。

function handleClick() {
  setCount((prev) => prev + 1);
  setCount((prev) => prev + 1);
  setCount((prev) => prev + 1);
}

この書き方ではReactが更新キューの中で直前のstateを順番に渡してくれます。

イメージとしては次のようになります。

初期値: 0

1つ目のupdater:
prev = 0
return 1

2つ目のupdater:
prev = 1
return 2

3つ目のupdater:
prev = 2
return 3

その結果countは期待通りに3増えます。

stale closureが起きる典型例

stateのスナップショットを理解していないと、stale closureにもハマりやすくなります。

たとえば次のようなコードです。

import { useState } from "react";

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

  function handleStart() {
    setInterval(() => {
      setCount(count + 1);
    }, 1000);
  }

  return (
    <>
      <p>count: {count}</p>
      <button onClick={handleStart}>Start</button>
    </>
  );
}

一見、1秒ごとにcountが増えそうです。
しかし、このコードではうまく増えないことがあります。

理由は、setIntervalのコールバックが、handleStartが実行された時点のcountを閉じ込めているからです。

たとえばcount0のときにhandleStartを実行すると、intervalの中ではずっとその時点のcount = 0が参照されます。

setCount(count + 1);
// 実質的には毎回
setCount(0 + 1);

その結果、count1のままになりやすいです。

この場合もfunctional updaterを使うと前回のstateに基づいて安全に更新できます。

import { useState } from "react";

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

  function handleStart() {
    setInterval(() => {
      setCount((prev) => prev + 1);
    }, 1000);
  }

  return (
    <>
      <p>count: {count}</p>
      <button onClick={handleStart}>Start</button>
    </>
  );
}

この書き方であればintervalのコールバックが古い countを閉じ込めていても、Reactが最新のstateをupdaterに渡してくれます。

フォーム送信でもstale closureは起きる

stale closureはタイマーだけの話ではありません。

たとえば、送信ボタンを押したあと、数秒後に処理を実行するケースを考えます。

function MessageForm() {
  const [message, setMessage] = useState("");
  const [to, setTo] = useState("Alice");

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();

    setTimeout(() => {
      alert(`${to} に「${message}」を送信しました`);
    }, 3000);
  }

  return (
    <form onSubmit={handleSubmit}>
      <select value={to} onChange={(e) => setTo(e.target.value)}>
        <option value="Alice">Alice</option>
        <option value="Bob">Bob</option>
      </select>

      <input
        value={message}
        onChange={(e) => setMessage(e.target.value)}
      />

      <button type="submit">Send</button>
    </form>
  );
}

このコードではsetTimeoutの中で使われるtomessageは、送信ボタンを押した時点の値です。

送信後、3秒以内に入力値や宛先を変更してもalertに表示されるのは送信時点の値です。

これはバグとは限りません。
むしろ「送信した瞬間の内容」を扱いたい場合には自然な挙動です。

重要なのは非同期処理の中で参照している値が「いつのレンダーの値なのか」を意識することです。

functional updaterを使うべき場面

functional updaterは、すべてのstate更新で必須というわけではありません。

次のように、次のstateが現在のイベントから直接決まる場合は、普通に値を渡せば十分です。

setKeyword(e.target.value);
setIsOpen(true);
setSelectedId(id);

一方で、次のstateが前回のstateに依存する場合は、functional updaterを使うほうが安全です。

setCount((prev) => prev + 1);
setIsOpen((prev) => !prev);
setItems((prev) => [...prev, newItem]);

判断基準はシンプルです。

次のstateが前回のstateに依存しない
→ 値を直接渡す

次のstateが前回のstateに依存する
→ functional updaterを使う

よくある判断例

入力値を更新する

setValue(e.target.value);

これはイベントから次の値が決まるので直接渡して問題ありません。

カウンターを増やす

setCount((prev) => prev + 1);

これは前回の値に依存するのでfunctional updaterが向いています。

booleanを反転する

setIsOpen((prev) => !prev);

これも前回の値に依存するため、functional updaterが自然です。

###配列に要素を追加する

setItems((prev) => [...prev, newItem]);

前回の配列に新しい要素を追加するため、functional updaterが安全です。

propsから受け取った値でstateを置き換える

setSelectedId(user.id);

これは次の値がuser.idで決まるのでfunctional updaterでなくても問題ありません。

state更新を読むときのチェックポイント

Reactのstate更新をレビューするときは、次の点を見るとバグを見つけやすくなります。

  • setState直後にstateの最新値を読もうとしていないか
  • 同じイベントハンドラ内でsetState(state + 1)を複数回呼んでいないか
  • 次のstateが前回のstateに依存しているのに、functional updaterを使っていないか
  • setTimeoutsetIntervalの中で古いstateを参照していないか
  • 非同期処理の中で使っている値が、どのレンダー時点の値なのか意識できているか
  • stateを更新する目的が「値の置き換え」なのか「前回値からの計算」なのか明確か

まとめ

Reactのstateは普通のJavaScript変数のようにその場で書き換わるものではありません。

state更新は現在の変数を直接変更するのではなく、次のレンダーを予約する処理です。
そして各レンダーのstateはスナップショットのように固定されています。

そのため、次のような理解が重要です。

  • setStateしても、現在実行中のstate変数は変わらない
  • イベントハンドラは、そのレンダー時点のstateを参照する
  • Reactは複数のstate更新をbatchingする
  • 前回のstateに基づく更新にはfunctional updaterを使う
  • 非同期処理ではstale closureに注意する

Reactのstate更新でハマる原因の多くは、APIの使い方そのものではなく、stateがどのタイミングの値なのかを誤解することにあります。

setStateは「今の値を書き換える命令」ではありません。
「次のレンダーで使う値をReactに伝える依頼」です。

この感覚を持てるようになると、カウンター、フォーム、タイマー、チャット、非同期処理など、さまざまな場面でstate更新をより安全に設計できるようになります。

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?