Skip to content

3.4 状態 (useState) — React に再レンダーを依頼する唯一の方法 ​

ここは Part 3 の中でも 一番の山場 です。ここさえ乗り越えれば、Part 3 の残りの章はだいぶ楽になります。急がず、一緒に見ていきましょう。

まず、この章で伝えたいことを 1 行で言います。

useState は、React に「もう一度画面を描き直してください」とお願いするための、特別な箱です。

ふつうの変数との違いは 2 つだけ。

  1. 再レンダーをまたいで 値が保たれる
  2. 値を更新する関数を呼ぶと、React が 再レンダーを始める

この 2 点の実感を掴むのが、この章のゴールです。

なぜ普通の変数ではダメなのか ​

まずは「普通の変数ではダメ」という話から始めます。直感と反する挙動なので、ここでつまずく人がとても多いところです。

次のコードは 動きません。ボタンを押しても画面の数字は 0 のまま変わりません。

tsx
// NG: 普通の変数でカウンターを作ろうとした例
function Counter() {
  let count = 0
  return (
    <button onClick={() => { count = count + 1 }}>
      {count}
    </button>
  )
}

公式ドキュメントはこの失敗を 2 点にまとめています。

  1. Local variables don't persist between renders.
  2. Changes to local variables won't trigger renders.

出典: react.dev — State: A Component's Memory

少し噛み砕きます。

  • Counter 関数は画面が更新されるたびに 何度も呼ばれます。呼ばれ直すと let count = 0 が毎回最初から実行されるので、前回書き換えた値は消えてしまいます。
  • さらに、普通の変数を書き換えても React はそれを 知りません。誰も「画面を描き直して」と伝えていないので、再レンダーは起きません。

つまり「値が消える」と「React が気づかない」の両方が同時に起きています。

useState はこの 2 つを一度に解決します。

  • state 変数 — 次のレンダーまで値を覚えておく役目
  • set 関数 — 値を更新し、同時に「再レンダーをお願いします」と React に伝える役目

図 3.4.1 普通の変数と useState の違い ​

普通の変数1 回目のレンダーlet count = 0関数が終わると count は消滅↓ 再レンダー2 回目のレンダーlet count = 0また 0 に戻ってしまうuseState1 回目のレンダーuseState(0) → 0値は React のメモに保管される↓ setCount(1) で再レンダー2 回目のレンダーuseState(0) → 1引数の 0 は無視され最新の 1 が返る

右側のポイントは、値そのものは React が覚えている ことです。Counter 関数が再実行されても、useState(0) は「前回しまってあった最新の値」を取り出して返します。初期値 0 は初回だけ使われます。

使い方 — useState ​

では実際の書き方です。

tsx
import { useState } from 'react'

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

  return (
    <button onClick={() => setCount(count + 1)}>
      {count}
    </button>
  )
}

シグネチャは公式の通りです。

js
const [state, setState] = useState(initialState)

useState returns an array with exactly two values:

  1. The current state. During the first render, it will match the initialState you have passed.
  2. The set function that lets you update the state to a different value and trigger a re-render.

出典: react.dev — useState Reference

戻り値は 必ず 2 要素の配列 なので、分割代入で名前を付けるのが慣例です。[count, setCount] のように「名詞 / setName」の組で書くと読みやすくなります。

再レンダーのメンタルモデル ​

React の動きを 3 段階に分けると、一気に霧が晴れます。

  1. Triggering a render
  2. Rendering the component
  3. Committing to the DOM

出典: react.dev — Render and Commit

  • Trigger (引き金) — setCount(...) が呼ばれる
  • Render (再実行) — React がコンポーネント関数を呼び直し、新しい JSX を受け取る
  • Commit (反映) — React が前回との差分だけを実 DOM に反映する

図 3.4.2 state 更新から画面反映まで ​

ここで 重要な一点 をもう一度強調します。Counter 関数は 毎回最初から実行されます。それでも useState(0) が返す値は、2 回目以降は「React が覚えている最新の値」です。引数の 0 は初回だけ使われ、2 回目以降は完全に無視されます。

This argument is ignored after the initial render.

出典: react.dev — useState Reference

state は「スナップショット」である ​

さて、ここが この章の最大の落とし穴 です。初めて見ると「えっ、なんで?」となる挙動を紹介します。

次のコードは期待通りに動きません。ボタンを押しても count は 3 ではなく 1 にしかなりません。

tsx
function handleClick() {
  setCount(count + 1)
  setCount(count + 1)
  setCount(count + 1)
  // count は 3 ではなく 1 になる
}

公式の説明はこうです。

The set function only updates the state variable for the next render. If you read the state variable after calling the set function, you will still get the old value that was on the screen before your call.

出典: 同上

噛み砕くと、こうなります。

  • この handleClick が呼ばれた時点の count は この関数の中では固定 です。仮にクリック前の count が 0 なら、この関数の中では count はずっと 0 です。
  • setCount(count + 1) は setCount(0 + 1)、つまり setCount(1) と同じ意味です。3 回書いても全部 setCount(1) なので、次のレンダーで count は 1 になります。

この「関数の中では state が変わらない」性質を、公式は スナップショット と呼びます。イベントハンドラが呼ばれた瞬間の state が、1 枚の写真のように関数内で固定される、という比喩です。

図 3.4.3 スナップショットとしての state ​

時間 →Render 1count = 0画面は 0 を表示ボタンクリックhandleClick 実行中関数の中では count = 0 に固定setCount(0 + 1) → 「次は 1」setCount(0 + 1) → 「次は 1」setCount(0 + 1) → 「次は 1」予約は全部同じ値(最終 1)再レンダーRender 2count = 1画面は 1 を表示

「setCount を呼んだ直後に count を読むと、もう新しい値が入っているはず」という 直感 が外れるのは、スナップショットのせいです。最初は違和感があると思いますが、「関数の中では state は写真」と覚えておくと混乱が減ります。

関数型更新で解決する ​

「直前の state をもとに次の state を計算したい」ときには、関数型更新 (updater function) を使います。

tsx
function handleClick() {
  setCount(c => c + 1)
  setCount(c => c + 1)
  setCount(c => c + 1)
  // count は 3 になる
}

今度はちゃんと 3 になります。公式の説明はこうです。

An updater function (e.g. n => n + 1) gets added to the queue. ... During the next render, React goes through the queue and gives you the final updated state.

出典: react.dev — Queueing a Series of State Updates

関数型更新の c => c + 1 の c は「この時点までの更新がすべて適用された state」が渡ってきます。呼び出し順に c: 0 → 1 → 2 と流れ、最終的に 3 になります。

ページ上で動くデモ ​

下のデモは実際に Vue(React ではないですが、同じ useState の挙動を模倣しています)で動いています。ボタンを押すたびに count がどう変わるかを観察してみてください。左が値ベース、右が関数型です。

BadsetCount(count + 1) × 3
count
0
  • (ボタンを押すと関数の中の動きが表示されます)

関数の中では count がスナップショットで固定されるので、 3 回呼んでも全部「次は同じ値」の予約になり、結果は 1 つぶんしか進みません。

GoodsetCount(c => c + 1) × 3
count
0
  • (ボタンを押すと関数の中の動きが表示されます)

関数型 c => c + 1 にすると、c は 「ここまでの更新が適用された最新の値」になります。3 回呼べば 3 つ積み上がります。

経験則: 直前の state を参照して更新するときは、いつも関数型 (setX(prev => ...)) を使うと覚えておいてください。値ベース (setX(x + 1)) はつまずきの温床なので、慣れるまではほぼ常に関数型で書く方が安全です。

オブジェクト / 配列の state はイミュータブルに ​

もう一つだけ、つまずきどころの話をします。state がオブジェクトや配列の場合、直接書き換えてはいけません。

tsx
const [form, setForm] = useState({ name: '', age: 0 })

// NG: ミューテーション (参照が同じなので React が変化に気づけない)
form.name = 'yuichkun'
setForm(form)

// OK: 新しいオブジェクトを作って渡す
setForm({ ...form, name: 'yuichkun' })

公式はこう言っています。

In React, state is considered read-only, so you should replace it rather than mutate your existing objects.

出典: react.dev — useState Reference

配列も同じです。push や splice ではなく、スプレッド構文や filter / map で 新しい配列を作って渡します。

tsx
const [items, setItems] = useState<string[]>([])

// 追加
setItems([...items, 'new'])
// 削除 (特定の index)
setItems(items.filter((_, i) => i !== 2))
// 更新
setItems(items.map((x, i) => (i === 2 ? 'updated' : x)))

なぜ書き換えではダメなのか。React は Object.is で「前回の state と今回の state が同じか」を判定し、同じなら再レンダーをスキップ します。直接書き換えると、オブジェクトの参照は同じままなので、React は「何も変わっていない」と判断してしまうのです。

Hook を呼ぶ場所のルール ​

useState のような関数を フック (Hook) と呼びます。呼ぶ場所には制約があるので、軽く触れておきます。

useState is a Hook, so you can only call it at the top level of your component or your own Hooks. You can't call it inside loops or conditions.

出典: 同上

tsx
// NG
function Bad({ flag }: { flag: boolean }) {
  if (flag) {
    const [x, setX] = useState(0) // NG: 条件分岐の中
  }
}

// OK
function Good({ flag }: { flag: boolean }) {
  const [x, setX] = useState(0) // OK: 先頭で無条件に
  if (flag) {
    // x を使う
  }
}

理由は少しだけ深い話になるので、興味があれば Part 6(おまけ)の 6.3 Hook はどう動いているか を覗いてみてください。端的には「React はフックが呼ばれた順番で state の置き場所を決めている」ため、順番が変わるとずれてしまうのです。

最初のうちは 「フックはコンポーネント関数の先頭で、無条件に並べて書く」 とだけ覚えておけば大丈夫です。

手を動かして確かめる ​

ここまでの話を実際のコードで触れると腑に落ちやすいです。下の StackBlitz でカウンターを触ってみてください(初回の起動に数秒かかります)。関数型更新とそうでない書き方の違いを、自分の目で比較するのがおすすめです。

StackBlitz で開く

まとめ ​

  • useState の戻り値は「このレンダー時点の state」と「set 関数」のペア。
  • set 関数を呼ぶと、React が次のレンダーをスケジュールする。関数本体はその中で再実行される。
  • イベントハンドラ内で読める state は スナップショット で、関数の中では変わらない。
  • 直前の値を参照して更新するときは setX(prev => ...) の関数型で書く。
  • オブジェクト / 配列は 新しい参照 を作って渡す(イミュータブル)。

ここまで消化できれば、この章は卒業です。最初から全部すっきり納得できなくて大丈夫です。スナップショットの話は特に、実際に書いてバグに出会ってから「ああこの話か」と腑に落ちるタイプの知識です。

さらに深く学ぶには ​

参照ソース ​