🔋 stateとは何か?
なぜstateが変わると再レンダリングが起きるのか。 ローカル変数では代替できない理由、useStateの保存場所、batchingまで深掘りします。
🤔 問題提起:なぜ普通の変数じゃダメなのか?
Reactを始めた人が最初に疑問に思うことの1つ——「ただの変数を使えばいいんじゃないか?」。 実際に試してみましょう。
// ❌ これは動かない
function Counter() {
let count = 0; // ローカル変数
function handleClick() {
count++; // 変数を変えても...
console.log(count); // → 値は増えている!
}
// でもUIには反映されない
return (
<div>
<p>{count}</p> {/* ← 常に0のまま */}
<button onClick={handleClick}>+1</button>
</div>
);
} countの値は増えているのに、画面は更新されません。
なぜでしょうか?この謎を解くには、Reactのレンダリングの仕組みを理解する必要があります。
🎯 結論から言う
stateとは「再レンダリングをトリガーする、Reactが管理する変数」です。
stateの変更はReactに通知され、Reactがコンポーネントを再実行することでUIが更新されます。
- 関数が実行されるたびに初期化される
- 変更してもReactに通知されない
- 再レンダリングが起きない
- 関数呼び出し間で値が保持されない
- レンダリング間で値が保持される
- 変更するとReactに通知される
- 再レンダリングがトリガーされる
- Reactが外部に値を保管している
🗺️ 構造図:stateの保存場所
Reactはstateをコンポーネント関数の「外」に保存しています。 具体的には、各コンポーネントに対応するFiberオブジェクトの中です。
📚 概念説明:Reactの内部でstateはどう保存されているか
Hookのリンクリスト
コンポーネントが複数のuseStateを持つとき、
Reactはそれらを連結リスト(リンクリスト)として管理します。
function UserProfile() {
const [name, setName] = useState(''); // Hook #1
const [age, setAge] = useState(0); // Hook #2
const [email, setEmail] = useState(''); // Hook #3
// ...
}
// Reactの内部(概念的な表現):
// Fiber.memoizedState = {
// memoizedState: '', ← name の値
// next: {
// memoizedState: 0, ← age の値
// next: {
// memoizedState: '', ← email の値
// next: null
// }
// }
// } なぜHooksを条件分岐の中で使えないのか?
ReactはHookを呼ばれた順番で管理しています。 条件分岐によってHookの呼び出し順が変わると、リストの「何番目のHookか」がずれて、 誤ったstateが返されます。これが「Hooksは条件分岐の中で使えない」という制約の理由です。
レンダリングのサイクル
コンポーネント関数は「レンダリングのたびに最初から実行される」ことを理解するのが重要です。
// 1回目のレンダリング(マウント)
function Counter() {
// useState(0) → Reactが新しいstateを作成して「0」を保存
// count = 0, setCount = dispatch関数
const [count, setCount] = useState(0);
return <button onClick={() => setCount(1)}>Click</button>;
}
// ボタンクリック後、2回目のレンダリング
function Counter() {
// useState(0) → Reactが既存のstateから「1」を取り出す
// 引数の「0」は無視される!
// count = 1, setCount = dispatch関数(同じ参照)
const [count, setCount] = useState(0);
return <button onClick={() => setCount(1)}>Click</button>;
} スナップショットとしてのstate
各レンダリングにおいて、stateはスナップショットです。
そのレンダリングの間、stateの値は変わりません。
setCountを呼んでも、
現在実行中のレンダリングのcountは変わらず、
次のレンダリングで新しい値が使われます。
💻 コード例:dispatch/queueの仕組み
setStateは即座にstateを変えない
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
console.log(count); // → まだ0! stateは変わっていない
setCount(count + 1);
console.log(count); // → まだ0!
// このclickハンドラが終わったあと、
// Reactは1回だけ再レンダリングを実行する
// でも count+1 を2回呼んでも、countは「0」なので
// 結果は 0+1=1 が2回 → 最終的に count = 1 になる
}
return <button onClick={handleClick}>Count: {count}</button>;
} 関数型の更新で解決する
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
// 関数を渡すと、Reactが「最新のstate」を引数として渡してくれる
setCount(prev => prev + 1); // prev = 0 → 1
setCount(prev => prev + 1); // prev = 1 → 2
setCount(prev => prev + 1); // prev = 2 → 3
// 3回インクリメントされる!
}
return <button onClick={handleClick}>Count: {count}</button>;
}
// 内部ではキューに更新関数が積まれる
// update queue: [prev => prev+1, prev => prev+1, prev => prev+1]
// Reactが順番に処理: 0 → 1 → 2 → 3 // Reactの内部(簡略版)
interface Update<S> {
action: S | ((state: S) => S); // 値 or 関数
next: Update<S> | null;
}
// setCount(value) が呼ばれたとき
function dispatchSetState(fiber, queue, action) {
const update = { action, next: null };
// キューに追加
const pending = queue.pending;
if (pending === null) {
update.next = update; // 環状リンクリスト
} else {
update.next = pending.next;
pending.next = update;
}
queue.pending = update;
// Reactに再レンダリングをスケジュール
scheduleUpdateOnFiber(fiber);
} 🔧 仕組み分解:Batching(バッチング)
Reactは複数のsetState呼び出しを
1つの再レンダリングにまとめます。これをバッチングと呼びます。
React 18以前のバッチング
// React 17以前: イベントハンドラ内のみバッチング
function handleClick() {
setCount(c => c + 1); // バッチ
setFlag(f => !f); // バッチ
// ↑ 2つまとめて1回の再レンダリング ✓
}
// イベントハンドラ外はバッチングされなかった(React 17以前)
setTimeout(() => {
setCount(c => c + 1); // 1回再レンダリング
setFlag(f => !f); // また1回再レンダリング
// 計2回のレンダリングが発生!
}, 0); React 18のAutomatic Batching
// React 18: どこでもバッチング(Automatic Batching)
setTimeout(() => {
setCount(c => c + 1); // バッチ
setFlag(f => !f); // バッチ
// ↑ 1回の再レンダリングにまとめられる ✓
}, 0);
fetch('/api/data').then(() => {
setData(result); // バッチ
setLoading(false); // バッチ
// ↑ これも1回の再レンダリング ✓
});
// バッチングを無効にしたい場合(稀なケース)
import { flushSync } from 'react-dom';
flushSync(() => {
setCount(c => c + 1); // 即座に再レンダリング
});
flushSync(() => {
setFlag(f => !f); // また即座に再レンダリング
}); バッチングはなぜ重要か?
再レンダリングはコストがかかります。複数のstateを更新するとき、 それぞれで再レンダリングが発生すると、中間状態のUIが一瞬表示されることもあります。 バッチングにより、すべての更新を1回の再レンダリングにまとめることで、 パフォーマンスと一貫性が向上します。
stateの更新が非同期な理由
// よくある誤解:setStateが非同期
function handleClick() {
setCount(5);
// この行でcountはまだ5ではない!
// countは現在のレンダリングのスナップショット(例:0)
console.log(count); // → 0
// 「最新のstate」を使いたいなら関数型更新
setCount(prev => {
console.log(prev); // → 5(前の更新が反映されている)
return prev + 1;
});
}
// setStateが「非同期」に見える理由:
// 1. setStateはupdateをキューに入れるだけ
// 2. 実際のstate変更と再レンダリングは
// 現在のイベントハンドラが終わった後に実行される
// 3. Promise のような本当の非同期ではなく、
// 「同期処理が終わってから」という意味 🌊 stateの「スナップショット」モデル
Reactの公式ドキュメントでは、stateを「スナップショット」と表現しています。 この概念を理解することで、多くのハマりポイントを回避できます。
// スナップショットを実感する例
function AlertTimer() {
const [message, setMessage] = useState('hello');
function handleClick() {
// ここでのmessageは「このレンダリング時点」の値
const snapshot = message; // "hello"
setTimeout(() => {
// 3秒後にmessageを参照しても...
alert(snapshot); // → "hello"(変更されていても!)
// setMessageが呼ばれてmessage="world"になっていても、
// このコールバックが閉じ込めているのは
// クリック時点のスナップショット
}, 3000);
setMessage('world');
}
return (
<div>
<input value={message} onChange={e => setMessage(e.target.value)} />
<button onClick={handleClick}>3秒後にalert</button>
</div>
);
}
// 解決策:最新のstateが欲しいならuseRef + useStateを組み合わせる 📌 まとめ
- ✓ ローカル変数はレンダリング間で値が保持されず、変更してもReactに通知されない
- ✓ stateはFiberオブジェクトの外部ストレージに保存され、レンダリング間で保持される
- ✓ useStateのstateはリンクリストで管理されるため、Hookの呼び出し順を変えてはいけない
- ✓ setStateは即座にstateを変えず、updateキューに追加してReactに再レンダリングを依頼する
- ✓ 関数型更新(prev => prev + 1)で最新のstateを基に更新できる
- ✓ React 18のAutomatic Batchingで、どこでも複数のsetStateが1回の再レンダリングにまとめられる
- ✓ stateは各レンダリングのスナップショット——クロージャが閉じ込めるのはその時点の値