3.4 状態 (useState) — React に再レンダーを依頼する唯一の方法
ここは Part 3 の中でも 一番の山場 です。ここさえ乗り越えれば、Part 3 の残りの章はだいぶ楽になります。急がず、一緒に見ていきましょう。
まず、この章で伝えたいことを 1 行で言います。
useStateは、React に「もう一度画面を描き直してください」とお願いするための、特別な箱です。
ふつうの変数との違いは 2 つだけ。
- 再レンダーをまたいで 値が保たれる
- 値を更新する関数を呼ぶと、React が 再レンダーを始める
この 2 点の実感を掴むのが、この章のゴールです。
なぜ普通の変数ではダメなのか
まずは「普通の変数ではダメ」という話から始めます。直感と反する挙動なので、ここでつまずく人がとても多いところです。
次のコードは 動きません。ボタンを押しても画面の数字は 0 のまま変わりません。
// NG: 普通の変数でカウンターを作ろうとした例
function Counter() {
let count = 0
return (
<button onClick={() => { count = count + 1 }}>
{count}
</button>
)
}公式ドキュメントはこの失敗を 2 点にまとめています。
- Local variables don't persist between renders.
- Changes to local variables won't trigger renders.
少し噛み砕きます。
Counter関数は画面が更新されるたびに 何度も呼ばれます。呼ばれ直すとlet count = 0が毎回最初から実行されるので、前回書き換えた値は消えてしまいます。- さらに、普通の変数を書き換えても React はそれを 知りません。誰も「画面を描き直して」と伝えていないので、再レンダーは起きません。
つまり「値が消える」と「React が気づかない」の両方が同時に起きています。
useState はこの 2 つを一度に解決します。
- state 変数 — 次のレンダーまで値を覚えておく役目
- set 関数 — 値を更新し、同時に「再レンダーをお願いします」と React に伝える役目
図 3.4.1 普通の変数と useState の違い
右側のポイントは、値そのものは React が覚えている ことです。Counter 関数が再実行されても、useState(0) は「前回しまってあった最新の値」を取り出して返します。初期値 0 は初回だけ使われます。
使い方 — useState
では実際の書き方です。
import { useState } from 'react'
function Counter() {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(count + 1)}>
{count}
</button>
)
}シグネチャは公式の通りです。
jsconst [state, setState] = useState(initialState)useState returns an array with exactly two values:
- The current state. During the first render, it will match the
initialStateyou have passed.- The
setfunction that lets you update the state to a different value and trigger a re-render.
戻り値は 必ず 2 要素の配列 なので、分割代入で名前を付けるのが慣例です。[count, setCount] のように「名詞 / setName」の組で書くと読みやすくなります。
再レンダーのメンタルモデル
React の動きを 3 段階に分けると、一気に霧が晴れます。
- Triggering a render
- Rendering the component
- Committing to the DOM
- 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.
state は「スナップショット」である
さて、ここが この章の最大の落とし穴 です。初めて見ると「えっ、なんで?」となる挙動を紹介します。
次のコードは期待通りに動きません。ボタンを押しても count は 3 ではなく 1 にしかなりません。
function handleClick() {
setCount(count + 1)
setCount(count + 1)
setCount(count + 1)
// count は 3 ではなく 1 になる
}公式の説明はこうです。
The
setfunction only updates the state variable for the next render. If you read the state variable after calling thesetfunction, 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
「setCount を呼んだ直後に count を読むと、もう新しい値が入っているはず」という 直感 が外れるのは、スナップショットのせいです。最初は違和感があると思いますが、「関数の中では state は写真」と覚えておくと混乱が減ります。
関数型更新で解決する
「直前の state をもとに次の state を計算したい」ときには、関数型更新 (updater function) を使います。
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.
関数型更新の c => c + 1 の c は「この時点までの更新がすべて適用された state」が渡ってきます。呼び出し順に c: 0 → 1 → 2 と流れ、最終的に 3 になります。
ページ上で動くデモ
下のデモは実際に Vue(React ではないですが、同じ useState の挙動を模倣しています)で動いています。ボタンを押すたびに count がどう変わるかを観察してみてください。左が値ベース、右が関数型です。
setCount(count + 1) × 3- (ボタンを押すと関数の中の動きが表示されます)
関数の中では count がスナップショットで固定されるので、 3 回呼んでも全部「次は同じ値」の予約になり、結果は 1 つぶんしか進みません。
setCount(c => c + 1) × 3- (ボタンを押すと関数の中の動きが表示されます)
関数型 c => c + 1 にすると、c は 「ここまでの更新が適用された最新の値」になります。3 回呼べば 3 つ積み上がります。
経験則: 直前の state を参照して更新するときは、いつも関数型 (setX(prev => ...)) を使うと覚えておいてください。値ベース (setX(x + 1)) はつまずきの温床なので、慣れるまではほぼ常に関数型で書く方が安全です。
オブジェクト / 配列の state はイミュータブルに
もう一つだけ、つまずきどころの話をします。state がオブジェクトや配列の場合、直接書き換えてはいけません。
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.
配列も同じです。push や splice ではなく、スプレッド構文や filter / map で 新しい配列を作って渡します。
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.
出典: 同上
// 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 でカウンターを触ってみてください(初回の起動に数秒かかります)。関数型更新とそうでない書き方の違いを、自分の目で比較するのがおすすめです。
まとめ
useStateの戻り値は「このレンダー時点の state」と「set 関数」のペア。- set 関数を呼ぶと、React が次のレンダーをスケジュールする。関数本体はその中で再実行される。
- イベントハンドラ内で読める state は スナップショット で、関数の中では変わらない。
- 直前の値を参照して更新するときは
setX(prev => ...)の関数型で書く。 - オブジェクト / 配列は 新しい参照 を作って渡す(イミュータブル)。
ここまで消化できれば、この章は卒業です。最初から全部すっきり納得できなくて大丈夫です。スナップショットの話は特に、実際に書いてバグに出会ってから「ああこの話か」と腑に落ちるタイプの知識です。
さらに深く学ぶには
- react.dev — useState Reference — 全オプション、初期化関数、caveats。
- react.dev — State as a Snapshot — スナップショットの詳細な説明。
- react.dev — Queueing a Series of State Updates — バッチングと関数型更新の裏側。
- react.dev — Render and Commit — レンダーとコミットの 3 段階。