React を書いていると、つい「また setState か……」と惰性で呼びがちな瞬間があります。けれど、毎晩 UI の舞台裏では、State と Fiber が手を取り合って"同じ入力から同じ出力を描く"ための綱引きをしています。本記事では、useState の基礎から実装の深掘り、典型的なユースケースまでをカバーし、「なぜ useState が必要なのか?」を改めて理解できる記事にしていきます!
検証環境: この記事は facebook/react リポジトリ(2025年12月時点の main ブランチ)の実際のソースコードを参照して書かれています。
注意: React の内部実装は頻繁に変更されます。この記事の内容は執筆時点での実装に基づいており、将来のバージョンでは変更される可能性があります。また、記事内のコードは理解を助けるために一部簡略化されています。完全な実装を知りたい場合は、実際のソースコードを参照してください。
1. なぜ useState が必要か — コンポーネントの「状態」再入門
React のコンポーネントは「状態」を持ち、状態に応じて UI を変化させることができます。この「状態」を管理するために必要なのが useState フックです。
1.1 「ただの変数」では UI と値がすぐズレる
ブラウザは render() の結果しか覚えていません。let count = 0 のようなローカル変数に代入しても、イベント後の再描画は起きず、表示は古い値のままです。React が再レンダーを発火できるのは、状態の変更を React に届けたときだけです。
const Counter = () => {
let count = 0;
const increment = () => count;
return <button onClick={increment}>{count}</button>;
};
<button onClick={() => setCount((c) => c + 1)}>+1</button>
1.2 useState の役割は「変更通知付きの値保管庫」
useState は次の二つを同時に行います。
- 値をコンポーネントごと・レンダーごとに記録する (Fiber が保持)
- セット関数の実行時に再レンダーを予約する
const [count, setCount] = useState(0);
setCount は即座に値を書き換えるのではなく、「次のレンダーで count を c1 にする」という更新パケットをキューへ積み、その後 React が差分描画を走らせます。
1.3 再レンダーが起きるまでの流れ
-
- ユーザー操作などで
setStateが呼ばれる
- ユーザー操作などで
-
- React が該当コンポーネントを 再実行
-
- 新しく返った JSX と前回の差分を DOM に適用
このループにより、UI=関数(state, props) という宣言的モデルが成立します。
このループにより、UI = 関数(state, props) という宣言的モデルが成立します。
1.4 ありがちな誤解と対策
-
「useState は非同期?」
1 回のイベント内ではバッチ処理されるため即時反映に見えないだけ。useEffectで DOM 反映後を観測可能。 -
「useRef だけで良い?」
ref は値を書き換えても描画を更新しません。表示に影響するなら state を使うべきです。 -
「setState 連続呼び出しで上書きされる?」
更新関数形式 (setCount((c) => c + 1)) を使えばキュー順に安全に計算できます。
つまり...Reactを使う以上、状態管理はuseStateを通じて行うのが正解です!
- メモ帳モード: ただの変数 = 自分のノート。書き換えても React は知りません。
-
受付モード:
setState= 受付に「番号札」を渡し、React が順番に処理して UI を貼り替える。
この例えで「状態を React に預ける」ことの意味を意識すると、useState の必然性が腑に落ちます。
2. useStateの内部構造を徹底解剖 — facebook/react ソースリーディング
useState を使うたびに、React の内部では複数のモジュールが連携して動いています。この章では、facebook/react リポジトリの実際のコードを追いながら、「useState(0) と書いてから UI が更新されるまで」の全工程を、初心者でも理解できるように段階的に解説します。
2.0 全体像: useState が動く仕組みの5つのステージ
まず、useState の処理フローを大まかに把握しましょう。以下の5つのステージを順に通過します:
📱 あなたのコード
↓
🎭 Dispatcher (交通整理役)
↓
📦 Hook ノード (状態の保管庫)
↓
📮 UpdateQueue (更新リクエストの待ち行列)
↓
⚙️ Scheduler (再レンダリングの実行役)
それぞれのステージで何が起きているのか、実際のコードと共に見ていきましょう。
2.1 エントリポイント: packages/react/src/ReactHooks.js
まず、あなたが useState を呼ぶとどこに飛ぶのか?
// packages/react/src/ReactHooks.js
export function useState<S>(initialState: (() => S) | S): [S, Dispatch<BasicStateAction<S>>] {
const dispatcher = resolveDispatcher();
return dispatcher.useState(initialState);
}
何が起きているか?
-
resolveDispatcher()で「現在の Dispatcher」を取得- Dispatcher とは、Hooks の実装を切り替える「交通整理役」です
-
ReactSharedInternals.H(内部的にはReactCurrentDispatcher.current) から、現在有効な Dispatcher を取り出します
-
取得した Dispatcher の
useStateを呼び出す- この時点では実際の処理はまだ行われず、適切な実装に処理を委譲しているだけです
なぜこのような仕組みなのか?
// ❌ こんな呼び方をするとエラー
const MyComponent = () => {
if (condition) {
const [count, setCount] = useState(0); // 条件分岐の中で呼ぶのはNG
}
}
// ❌ こんな呼び方もエラー
function regularFunction() {
const [count, setCount] = useState(0); // React コンポーネント外での呼び出しはNG
}
Dispatcher が取得できない状況(Hooks を関数外で呼んだ、コンポーネントのトップレベル以外で呼んだ等)では、resolveDispatcher() が null を返し、DEV モードで Invalid hook call 警告が出ます。
💡 ポイント:
useStateは常に Dispatcher を経由することで、React が「今どの状況で呼ばれているか」を制御できるようになっています。
2.2 Dispatcher の切り替え: ReactFiberHooks.js
次に、Dispatcher はどうやって「マウント時」と「更新時」を判断しているのか?
React は、コンポーネントが**初めて表示される時(マウント)と再レンダリングされる時(更新)**で、異なる処理を行う必要があります。
// packages/react-reconciler/src/ReactFiberHooks.js
// コンポーネントをレンダリングする直前に実行される
ReactSharedInternals.H =
current === null || current.memoizedState === null
? HooksDispatcherOnMount // 初回マウント用
: HooksDispatcherOnUpdate; // 再レンダリング用
2つの Dispatcher の違い
| Dispatcher | 使われる場面 | useState の実装 |
|---|---|---|
HooksDispatcherOnMount |
コンポーネントが初めて表示される時 |
mountState 関数 |
HooksDispatcherOnUpdate |
コンポーネントが再レンダリングされる時 |
updateState 関数 |
renderWithHooks の役割
// packages/react-reconciler/src/ReactFiberHooks.js
export function renderWithHooks(
current,
workInProgress,
Component,
props,
...
) {
// 1. Dispatcher を設定
ReactSharedInternals.H = /* マウントか更新かで切り替え */;
// 2. コンポーネントを実行(この中で useState が呼ばれる)
let children = Component(props);
// 3. Dispatcher をリセット
ReactSharedInternals.H = ContextOnlyDispatcher;
return children;
}
この仕組みにより、コンポーネント実行中だけ有効な Dispatcher が設定され、それ以外の場所で Hooks を呼ぶとエラーになります。
💡 ポイント: Dispatcher の切り替えは、「Hooks をトップレベルでしか呼べない」というルールの技術的な実装です。条件分岐やループの中で呼ぶと、Dispatcher が想定外の状態になってしまいます。
2.3 初回マウント時: mountState の詳細
コンポーネントが初めて表示される時、useState は何をしているのか?
// packages/react-reconciler/src/ReactFiberHooks.js
function mountStateImpl(initialState) {
const hook = mountWorkInProgressHook();
if (typeof initialState === 'function') {
const initialStateInitializer = initialState;
initialState = initialStateInitializer();
// StrictMode では初期化関数を2回実行して副作用を検出
if (shouldDoubleInvokeUserFnsInHooksDEV) {
setIsStrictModeForDevtools(true);
try {
initialStateInitializer();
} finally {
setIsStrictModeForDevtools(false);
}
}
}
hook.memoizedState = hook.baseState = initialState;
const queue = {
pending: null,
lanes: NoLanes,
dispatch: null,
lastRenderedReducer: basicStateReducer,
lastRenderedState: initialState,
};
hook.queue = queue;
return hook;
}
function mountState(initialState) {
const hook = mountStateImpl(initialState);
const queue = hook.queue;
const dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, queue);
queue.dispatch = dispatch;
return [hook.memoizedState, dispatch];
}
ステップバイステップ解説
実際のReactでは、mountState は mountStateImpl と mountState の2つの関数に分かれています。これは内部実装の整理のためです。
ステップ1: Hook ノードを作成
const hook = mountWorkInProgressHook();
Hook ノードは、useState の状態を保存するための「箱」です。コンポーネントが持つ複数の Hooks は、リンクリスト構造で保存されます:
Component Fiber
└─ memoizedState (最初の Hook)
├─ memoizedState: 0 (useState の値)
├─ queue: UpdateQueue (更新情報)
├─ next ──→ 次の Hook
│ ├─ memoizedState: "value" (別の useState)
│ ├─ queue: UpdateQueue
│ └─ next ──→ さらに次の Hook...
ステップ2-3: 初期値の計算と保存
// パターン1: 直接値を渡す
const [count, setCount] = useState(0);
// → initialState = 0
// パターン2: 関数を渡す(lazy initialization)
const [data, setData] = useState(() => {
const expensive = heavyComputation(); // 1回だけ実行される
return expensive;
});
// → initialState = heavyComputation() の結果
関数形式で渡すと、初回マウント時に1度だけ実行されます。StrictMode の二重レンダリングでも安全です(純粋関数なら)。
ステップ4: UpdateQueue の作成
UpdateQueue は「setState が呼ばれた時の更新リクエスト」を保存する待ち行列です:
const queue = {
pending: null, // 未処理の更新を循環リストで保持
lanes: NoLanes, // 優先度情報
dispatch: null, // setState 関数
lastRenderedReducer: basicStateReducer, // 更新計算ロジック
lastRenderedState: initialState, // 前回レンダリング時の値
};
ステップ5-6: dispatch 関数の作成
const dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, queue);
この dispatch が、あなたのコンポーネントに返される setCount などの関数です。bind により、どの Fiber のどの Queue に更新を積むかが固定されます。
💡 ポイント:
mountStateは「状態の保管庫(Hook ノード)」と「更新の受付窓口(dispatch 関数)」を同時に作成しています。
2.4 再レンダリング時: updateState の詳細
2回目以降のレンダリングでは何が起きるのか?
// packages/react-reconciler/src/ReactFiberHooks.js
function updateState(initialState) {
return updateReducer(basicStateReducer, initialState);
}
function updateReducer(reducer, initialArg) {
const hook = updateWorkInProgressHook();
return updateReducerImpl(hook, currentHook, reducer);
}
function updateReducerImpl(hook, current, reducer) {
const queue = hook.queue;
queue.lastRenderedReducer = reducer;
// ステップ1: pending queue を base queue にマージ
let baseQueue = hook.baseQueue;
const pendingQueue = queue.pending;
if (pendingQueue !== null) {
// 保留中の更新をベースキューに統合
if (baseQueue !== null) {
const baseFirst = baseQueue.next;
const pendingFirst = pendingQueue.next;
baseQueue.next = pendingFirst;
pendingQueue.next = baseFirst;
}
current.baseQueue = baseQueue = pendingQueue;
queue.pending = null;
}
// ステップ2: 更新キューを処理
if (baseQueue !== null) {
const first = baseQueue.next;
let newState = hook.baseState;
let update = first;
do {
// 優先度チェック、楽観的更新の処理などは省略...
const action = update.action;
if (update.hasEagerState) {
// Eager state が利用可能ならそれを使う
newState = update.eagerState;
} else {
// reducer を実行して新しい状態を計算
newState = reducer(newState, action);
}
update = update.next;
} while (update !== null && update !== first);
// ステップ3: 差分チェック
if (!is(newState, hook.memoizedState)) {
markWorkInProgressReceivedUpdate();
}
hook.memoizedState = newState;
hook.baseState = newState;
hook.baseQueue = null; // 処理済み
queue.lastRenderedState = newState;
}
// ステップ4: [新しい状態値, dispatch関数] を返す
const dispatch = queue.dispatch;
return [hook.memoizedState, dispatch];
}
注: 実際の
updateReducerImplはさらに複雑で、優先度(Lane)による更新のスキップ、楽観的更新(Optimistic Update)の処理、非同期アクション(Async Action)の処理など、多くの機能が含まれています。上記は理解しやすくするために簡略化したものです。
重要な処理
1. Hook の順序に依存した取得
const hook = updateWorkInProgressHook();
updateWorkInProgressHook は、前回のレンダリング時の Hook リストを順番に辿る処理です。だから以下のようなコードは壊れます:
// ❌ 条件分岐で Hook の順序が変わる
const MyComponent = ({ flag }) => {
if (flag) {
const [a, setA] = useState(1); // 1番目の Hook(flagがtrueの時だけ)
}
const [b, setB] = useState(2); // flagによって1番目だったり2番目だったり
};
2. 更新の適用とバッチ処理
do {
const action = update.action;
if (update.hasEagerState) {
newState = update.eagerState; // 事前計算済みの値を使用
} else {
newState = reducer(newState, action);
}
update = update.next;
} while (update !== null && update !== first);
1つのイベント内で複数回 setState が呼ばれた場合、それらはすべてキューに積まれ、ここでまとめて計算されます:
const handleClick = () => {
setCount(0); // update 1
setCount(1); // update 2
setCount(2); // update 3
// 最終的に count は 2 になる
};
3. 差分判定による最適化
if (!is(newState, hook.memoizedState)) {
markWorkInProgressReceivedUpdate();
}
React内部では is 関数(Object.is のエイリアス)で値が変わっていなければ、再レンダリングをスキップ(bailout)します:
const [count, setCount] = useState(0);
setCount(0); // 同じ値なので再レンダリングされない
💡 ポイント:
updateStateは既存の Hook ノードを再利用し、キューに積まれた更新を順番に適用して新しい値を計算します。
2.5 更新のトリガー: dispatchSetState の詳細
setCount(1) を呼んだ時、内部で何が起きるのか?
// packages/react-reconciler/src/ReactFiberHooks.js
function dispatchSetState(fiber, queue, action) {
const lane = requestUpdateLane(fiber);
const didScheduleUpdate = dispatchSetStateInternal(fiber, queue, action, lane);
if (didScheduleUpdate) {
startUpdateTimerByLane(lane, 'setState()', fiber);
}
markUpdateInDevTools(fiber, lane, action);
}
function dispatchSetStateInternal(fiber, queue, action, lane) {
// ステップ1: 更新オブジェクトを作成
const update = {
lane, // 優先度情報
revertLane: NoLane, // 楽観的更新用のレーン
gesture: null, // ジェスチャーTransition用
action, // setCount に渡された値 or 関数
hasEagerState: false, // 事前計算フラグ
eagerState: null, // 事前計算結果
next: null, // 次の更新へのポインタ
};
// ステップ2: レンダリング中かどうかで処理を分岐
if (isRenderPhaseUpdate(fiber)) {
// レンダリング中に setState が呼ばれた場合
enqueueRenderPhaseUpdate(queue, update);
} else {
// 通常のイベントハンドラなどから呼ばれた場合
// 【最適化】現在のレンダリングがアイドルなら事前計算
const alternate = fiber.alternate;
if (
fiber.lanes === NoLanes &&
(alternate === null || alternate.lanes === NoLanes)
) {
const lastRenderedReducer = queue.lastRenderedReducer;
if (lastRenderedReducer !== null) {
try {
const currentState = queue.lastRenderedState;
const eagerState = lastRenderedReducer(currentState, action);
update.hasEagerState = true;
update.eagerState = eagerState;
// 値が変わっていなければここで終了(bailout)
if (is(eagerState, currentState)) {
enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
return false; // 再レンダリング不要!
}
} catch {}
}
}
// ステップ3: 更新をキューに追加
const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
// ステップ4: 再レンダリングをスケジュール
if (root !== null) {
scheduleUpdateOnFiber(root, fiber, lane);
entangleTransitionUpdate(root, queue, lane);
return true;
}
}
return false;
}
ステップバイステップ解説
実際のReactでは、dispatchSetState は処理を dispatchSetStateInternal に委譲し、パフォーマンス計測やDevToolsへの通知を行います。
ステップ1: 優先度(Lane)の決定と更新オブジェクトの作成
React は Concurrent Mode で複数の更新に優先度を付けます:
// 高優先度(ユーザー入力など)
<input onChange={(e) => setValue(e.target.value)} />
// 低優先度(startTransition で包む)
startTransition(() => {
setFilteredList(hugeList.filter(item => item.includes(value)));
});
requestUpdateLane は、現在のコンテキストから適切な優先度を判断します。
更新オブジェクトの構造:
const update = {
lane: SyncLane, // 例: 同期優先度
revertLane: NoLane, // 楽観的更新のロールバック用
gesture: null, // ジェスチャーTransition用
action: 1, // setCount(1) なら 1
// または
action: (c) => c + 1, // setCount(c => c + 1) なら関数
hasEagerState: false,
eagerState: null,
next: null,
};
ステップ2: Eager State(事前計算)による最適化
// 現在の値が 5 の時
setCount(5); // 同じ値
// → is(5, 5) === true (Object.is のエイリアス)
// → 再レンダリングせずに return
この最適化により、無駄な再レンダリングを防ぎます。
注: Reactは内部で
is関数を使用していますが、これはObject.isが利用可能な環境ではそれを使い、そうでない環境ではポリフィルを使う実装になっています(packages/shared/objectIs.js参照)。
ステップ3-4: 更新のキューイングとスケジューリング
enqueueConcurrentHookUpdate(fiber, queue, update, lane);
scheduleUpdateOnFiber(root, fiber, lane);
entangleTransitionUpdate(root, queue, lane);
-
enqueueConcurrentHookUpdate: 更新を Hook の queue に追加 -
scheduleUpdateOnFiber: Fiber ツリーのルートから再レンダリングを開始 -
entangleTransitionUpdate: Transition の更新を関連付け
この後、React は:
- 仮想 DOM の差分計算
- コンポーネントの再実行
- DOM への反映
という流れを実行します。
💡 ポイント:
dispatchSetStateは「更新リクエストの受付」と「再レンダリングのスケジューリング」を担当します。即座に値を変えるのではなく、キューに積んで後で処理する非同期的な設計です。
2.6 状態の更新計算: basicStateReducer
キューに積まれた更新はどうやって新しい値に変換されるのか?
// packages/react-reconciler/src/ReactFiberHooks.js
function basicStateReducer(state, action) {
// action が関数なら、現在の state を渡して実行
return typeof action === 'function' ? action(state) : action;
}
この単純な関数が、useState の更新ロジックの核心です。
2つの setState パターン
パターン1: 直接値を渡す
const [count, setCount] = useState(0);
setCount(1); // action = 1
// basicStateReducer(0, 1) → 1
パターン2: 更新関数を渡す
setCount((prevCount) => prevCount + 1); // action = (prevCount) => prevCount + 1
// basicStateReducer(0, (c) => c + 1) → 1
なぜ更新関数形式が必要か?
// ❌ 直接値を連続で呼ぶと...
const handleClick = () => {
setCount(count + 1); // count が 0 の時、1 を設定
setCount(count + 1); // count はまだ 0 なので、1 を設定
setCount(count + 1); // count はまだ 0 なので、1 を設定
// 結果: count は 1 になる(3 ではない)
};
// ✅ 更新関数を使うと...
const handleClick = () => {
setCount((c) => c + 1); // 0 → 1
setCount((c) => c + 1); // 1 → 2
setCount((c) => c + 1); // 2 → 3
// 結果: count は 3 になる
};
更新関数形式は、前の更新の結果を受け取るため、連続した更新が正しく積み重なります。
💡 ポイント:
basicStateReducerは非常にシンプルですが、「値をそのまま使うか、関数として実行するか」を判断する重要な役割を持っています。
2.7 Hook 順序の監視: 開発時の安全装置
なぜ条件分岐の中で Hooks を呼んではいけないのか?技術的な理由
// packages/react-reconciler/src/ReactFiberHooks.js (DEV モードのみ)
let hookTypesDev = null; // 初回レンダリング時の Hook 種類を記録
let hookTypesUpdateIndexDev = -1;
function updateWorkInProgressHook() {
// ...
if (__DEV__) {
// 現在の Hook の種類を記録
const currentHookType = /* 'useState', 'useEffect' など */;
// 前回の記録と照合
if (hookTypesDev !== null) {
hookTypesUpdateIndexDev++;
if (hookTypesDev[hookTypesUpdateIndexDev] !== currentHookType) {
warnOnHookMismatchInDev(currentHookType);
}
}
}
// ...
}
具体的な警告例
// 初回レンダリング: flag = true
const MyComponent = ({ flag }) => {
if (flag) {
const [a, setA] = useState(1); // Hook 0: useState
}
const [b, setB] = useState(2); // Hook 1: useState
useEffect(() => {}, []); // Hook 2: useEffect
};
// hookTypesDev = ['useState', 'useState', 'useEffect']
// 2回目のレンダリング: flag = false
const MyComponent = ({ flag }) => {
if (flag) {
// このブロックは実行されない
}
const [b, setB] = useState(2); // Hook 0: useState ← 前回は Hook 1 だった!
useEffect(() => {}, []); // Hook 1: useEffect ← 前回は Hook 2 だった!
};
// 順序が変わったので警告が出る
React DevTools には以下のような詳細な警告が表示されます:
Warning: React has detected a change in the order of Hooks called by MyComponent.
This will lead to bugs and errors if not fixed.
Previous render Next render
---------------------------------------
1. useState useState
2. useState useEffect ⚠️
3. useEffect ⚠️
---------------------------------------
💡 ポイント: Hooks は「順番」で識別されているため、レンダリング間で順序が変わると正しく動作しません。DEV モードの警告は、このルール違反を早期に検出するための仕組みです。
2.8 全体の流れを図解で理解する
フロー1: 初回マウント時
フロー2: setState 呼び出し時
フロー3: 再レンダリング時
2.9 実際のコードで動作を確認してみる
以下のコードで、内部の動きを意識しながら実行してみましょう:
import { useState } from 'react';
const Counter = () => {
console.log('🎬 コンポーネント実行開始');
// 1回目: mountState が呼ばれ、Hook ノードが作成される
// 2回目以降: updateState が呼ばれ、既存の Hook を取得
const [count, setCount] = useState(() => {
console.log('🔧 初期化関数が実行(初回のみ)');
return 0;
});
console.log('📊 現在の count:', count);
const increment = () => {
console.log('➕ increment 実行');
// dispatchSetState が呼ばれる
setCount((prev) => {
console.log('🔄 更新関数実行: prev =', prev);
return prev + 1;
});
console.log('⏰ setCount 呼び出し完了(まだ count は更新されていない)');
console.log(' 現在の count:', count); // 古い値のまま
};
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+1</button>
</div>
);
};
実行ログの例
// 初回レンダリング
🎬 コンポーネント実行開始
🔧 初期化関数が実行(初回のみ)
📊 現在の count: 0
// ボタンクリック
➕ increment 実行
⏰ setCount 呼び出し完了(まだ count は更新されていない)
現在の count: 0
// 再レンダリング
🎬 コンポーネント実行開始
🔄 更新関数実行: prev = 0
📊 現在の count: 1
💡 ポイント:
setCountを呼んでも即座にcountは変わりません。次のレンダリングで初めて新しい値が取得できます。
2.10 まとめ: useState の内部構造
長い説明でしたが、重要なポイントをまとめます:
useState が動く仕組みの5ステージ(再掲)
-
Dispatcher: あなたの
useState呼び出しを、マウント or 更新処理に振り分ける - mountState / updateState: Hook ノードを作成 or 取得し、値を計算する
-
dispatchSetState:
setCount呼び出しを受け付け、更新をキューに積む - UpdateQueue: 複数の更新を保持し、バッチ処理する
- Scheduler: 優先度に基づいて再レンダリングを実行する
重要な設計原則
| 原則 | 理由 |
|---|---|
| Hooks はトップレベルでのみ呼ぶ | Hook の順序で識別しているため |
| setState は非同期 | 更新をキューに積んで後でバッチ処理するため |
| 更新関数形式を使う | 連続した更新を正しく積み重ねるため |
| イミュータブルに更新 | Object.is での差分判定のため |
内部で使われている主要な概念
- Fiber: コンポーネントの状態やHooksを保持するノード
- Hook ノード: 各 Hooks の値や queue を保存するリンクリスト
- UpdateQueue: setState の呼び出しを保存する待ち行列
- Lane: 更新の優先度を表す情報
- Dispatcher: 状況に応じて Hooks の実装を切り替える仕組み
これらの仕組みが組み合わさって、useState の「シンプルな API」が実現されています。
3. 代表的ユースケース & パターン
3.1 フォームと同期する
const [form, setForm] = useState({ name: '', email: '' });
const handleChange = (field: keyof typeof form) => (e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setForm(prev => ({ ...prev, [field]: value }));
};
- イミュータブル更新と TypeScript の
keyofで型安全。 - 大規模フォームでは
useReducerへ移行する判断軸を用意しておく(setFormの呼び出しが複雑になったら reducer へ)。
useReducerに切り替えるラインは「状態の更新ロジックを 1 箇所に集約したい」と感じた時。useStateを無理に使い続けず、ロジックを reducer に押し込むとテストもしやすい。
3.2 レイジー初期化 キャッシュ
const [rows] = useState(() => expensiveParse(csvText));
- Qiita:@80andco_tech_pr にも記載された StrictMode の二重実行対策として、
expensiveParseは純粋で副作用を持たないよう実装。
3.3 カスタム Hook の素材として
function useBoolean(initial = false) {
const [value, setValue] = useState(initial);
const setTrue = useCallback(() => setValue(true), []);
const setFalse = useCallback(() => setValue(false), []);
const toggle = useCallback(() => setValue(v => !v), []);
return { value, setTrue, setFalse, toggle };
}
-
useStateを薄く包んで再利用パターンを提供。
企業内 Design System でも「モーダルの open/close」を司る
useBooleanのような簡易カスタム Hook がよく使われる。useStateの知識はそのまま応用できる。
3.4 外部ストアとの併用
-
useSyncExternalStoreの導入以降も、ローカル UI 状態はuseStateで、サーバーキャッシュは外部ストアに任せるなど粒度を分けるとシンプル。
React Server Components でもローカル UI の微調整は結局クライアントで
useStateが担う。GraphQL キャッシュや Zustand などと住み分け、UI 層の責務を明確にすると設計がぶれない。
4. デバッグとパフォーマンス
- React DevTools: Hooks タブで State の履歴を確認できる。
- StrictMode: 開発中の二重実行により不純な初期化を早期発見。
-
Profiler: どの
setStateが頻繁に走っているかを特定。 -
useTransition/useDeferredValue:useState更新の優先度調整に利用。
さらに
React.Profilerコンポーネントでレンダリング時間を記録し、setState呼び出しが多すぎないかを把握するのも有効。必要に応じてmemoやuseMemoと組み合わせる。
5. 参考リンクとまとめ
5.1 実際のソースコード参照
この記事で解説した内容は、facebook/react リポジトリの以下のファイルに基づいています:
エントリポイント
-
packages/react/src/ReactHooks.js-
useStateのエクスポート関数 -
resolveDispatcherによる Dispatcher の取得
-
コア実装
-
packages/react-reconciler/src/ReactFiberHooks.js-
renderWithHooks: Dispatcher の切り替えとコンポーネント実行 -
mountStateImpl: 初回マウント時の Hook 作成(内部実装) -
mountState: 初回マウント時の公開関数 -
updateState: 再レンダリング時の状態更新 -
updateReducer/updateReducerImpl: 更新キューの処理 -
dispatchSetState/dispatchSetStateInternal: setState の実装 -
basicStateReducer: useState の更新ロジック -
HooksDispatcherOnMount: マウント時の Dispatcher オブジェクト -
HooksDispatcherOnUpdate: 更新時の Dispatcher オブジェクト
-
ユーティリティ
-
packages/shared/objectIs.js-
is関数: Object.is のポリフィル実装 - 差分判定に使用される
-
型定義
-
packages/react-reconciler/src/ReactInternalTypes.js-
Hook型: Hook ノードの構造 -
UpdateQueue型: 更新キューの構造 -
Update型: 個別の更新オブジェクト -
Dispatcher型: Hooks の実装を集めたオブジェクト
-
皆さんがソースコードを読む際のヒント
-
DEV モードと本番モードの違い:
if (__DEV__)ブロックが多数あります。開発時の警告やデバッグ機能はここに含まれています。 -
Flow の型注釈: React のコアは TypeScript ではなく Flow で書かれています。
// @flowや: Typeといった記法が見られます。 -
Feature Flags:
enableGestureTransitionのような条件分岐は、実験的機能のフラグです。packages/shared/ReactFeatureFlags.jsで定義されています。 -
複雑な最適化: 実際のコードには、Concurrent Mode、Suspense、Transition、Optimistic Update など、この記事で簡略化した多くの機能が含まれています。
-
useStateは「UI を再計算させるトリガー」と「レンダリング間で値を保持する箱」のペア。 - 内部では Hook リスト & UpdateQueue & Lane ベースのスケジューラが動作しており、React Fiber が差分再レンダリングと優先度制御を担っている。
- ルール(トップレベルで呼ぶ、イミュータブル更新、純粋な初期化)を守れば、
useStateは最小コストで表現力豊かな状態管理を提供する。 - 実際のソースコードは、この記事で説明した内容に加えて、多くの最適化と機能拡張が含まれています。興味があれば、facebook/react リポジトリを直接読んでみることをお勧めします。
状態管理の分岐点は「ローカル UI に閉じるか/グローバルに共有するか」。
useStateで十分な粒度を見極めつつ、必要に応じてuseReducerや外部ストアへ拡張していくのが実践的なアプローチである。