React Quiz (React Tokyo Confポスターセッション回答)
この記事は、React TokyoのポスターセッションのReact Quizの回答用に作成しています。
※当日セッションに参加されていなくてもこの記事だけで独立したコンテンツとしてお楽しみいただけます。
初級編:子コンポーネントの描画順序
function A(props) {
console.log("1");
return <B />;
}
function B(props){
console.log("2")
return <div />
}
function App() {
const [state, setState] = useState(false)
useEffect(() => {setState(true)}, [])
return (
<div>
<A state={state} />
<B />
</div>
);
}
選択肢
-
A.
1, 2, 2 -
B.
1, 2, 2, 1, 2 -
C.
1, 2, 2, 1, 2, 2 -
D.
1, 2, 1, 2
答えを見る
C. 1, 2, 2, 1, 2, 2
初回レンダリングで 1, 2, 2、useEffect による再レンダリングで再び 1, 2, 2 が出力されます。
解説
初回レンダリング: 1, 2, 2
コンポーネントツリーを上から順にたどります。
App
├── A (state={false}) → console.log("1")
│ └── B → console.log("2") ← A の return に含まれる B
└── B → console.log("2") ← App 直下の B
A が return <B /> しているため、App 直下の <B /> とは別に A の子としても B がレンダリングされます。
useEffect による再レンダリング: 1, 2, 2
useEffect(() => {setState(true)}, []) がコミットフェーズで発火します。
-
setState(true)— 現在の state はfalse、新しい値はtrue -
Object.is(false, true)→false(値が異なる) - → 再レンダリングが発生
この問題では React.memo でラップされたコンポーネントがないため、App 以下のツリー全体が再描画され、再び 1, 2, 2 が出力されます。
React 内部ではどう処理されているか
この「親の更新 → 子全体の再レンダリング」は、ReactFiberBeginWork.js の以下の流れで実現されています。
-
beginWork()が Fiber ツリーを上から順に処理する - state が変わったコンポーネントでは
didReceiveUpdate = true(L4165-4167)が設定される -
updateFunctionComponent()内でrenderWithHooks()を実行し、新しい JSX を生成 - その JSX を
reconcileChildren()に渡し、子 Fiber すべてを再レンダリング対象にする
beginWork(App fiber)
↓ state changed → didReceiveUpdate = true
↓ renderWithHooks() → 新しい JSX を生成
↓ reconcileChildren() → 子 Fiber の pendingProps を更新
↓
beginWork(A fiber) ← props が同じでも再実行される
↓
beginWork(B fiber) ← 同上
ポイントは、親がレンダリングされた時点で 子の JSX も新たに生成される ことです。子 Fiber の pendingProps が新しいオブジェクトで更新されるため、たとえ値が同じでも子は beginWork() の処理対象になります。
React.memo はこの流れの中で updateSimpleMemoComponent() が割り込み、新旧 props を shallowEqual で比較して同じなら bailoutOnAlreadyFinishedWork() でサブツリーごとスキップします。
まとめ
[初回レンダリング] → 1, 2, 2
[useEffect再レンダリング] → 1, 2, 2
─────────────────────────
合計出力: 1, 2, 2, 1, 2, 2
中級編:setState したのに再レンダリングしない?
function A(props) {
console.log("1");
return null;
}
const B = React.memo(function B(props) {
console.log("2");
return null;
});
function App() {
const nested = { nested: { nested: true } };
const [state, setState] = useState(nested);
useEffect(() => {
setState(nested);
}, []);
return (
<>
<A nested={nested} />
<B nested={nested} />
</>
);
}
選択肢
-
A.
1, 2 -
B.
1, 2, 1 -
C.
1, 2, 2 -
D.
1, 2, 1, 2
答えを見る
A. 1, 2
初回レンダリングの 1回分のみ。useEffect による setState では再レンダリングが発生しません。
不正解の選択肢はなぜ間違いなのか
| 選択肢 | 回答 | 誤った思考パターン |
|---|---|---|
| B | 1, 2, 1 |
「再レンダリングは起きるが React.memo が B を防ぐ」 |
| D | 1, 2, 1, 2 |
「setState すれば必ず再レンダリングする」 |
| その他 | 無限ループ | 「毎回新しいオブジェクトだからループする」 |
いずれも不正解です。そもそも再レンダリング自体が発生しません。
解説:鍵は useEffect の実行タイミングとクロージャ
なぜ setState しているのに再レンダリングが起きないのか。理解するには、useEffect がいつ実行されるか を正確に知る必要があります。
useEffect はコミットフェーズで実行される
React のライフサイクルは大きく レンダーフェーズ と コミットフェーズ に分かれます。
[レンダーフェーズ] [コミットフェーズ]
コンポーネント関数を実行 DOM に反映
↓ ↓
const nested = {...} useEffect(() => {
useState(nested) setState(nested) ← ここで実行
return <JSX /> })
useEffect のコールバックは 同じレンダリングサイクルのコミットフェーズ で実行されます。次のレンダーフェーズではありません。つまり、コールバック内の nested は そのレンダーで生成された nested と同じ変数 であり、useState の初期値として渡した nested と 同一のオブジェクト参照 です。
クロージャが参照の同一性を保証する
初回レンダリング時:
nested = {nested: {nested: true}} ← これを obj1 とする
useState(obj1) → state = obj1
useEffect(() => setState(obj1)) ← obj1 をクロージャでキャプチャ
コミットフェーズで useEffect 発火:
setState(obj1)
React が Object.is(obj1, obj1) で比較
→ true(同一参照)
→ 再レンダリングをスキップ(bailout)
React Reconciler 内部の bailout 判定を読む
では、React は具体的にどこでこの判定をしているのでしょうか?
react-reconciler/src/ReactFiberHooks.js の dispatchSetStateInternal 関数内に、その答えがあります。
// ReactFiberHooks.js L3672
if (is(eagerState, currentState)) {
// Fast path. We can bail out without scheduling React to re-render.
enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
return false;
}
ここで使われている is は Object.is のラッパー(shared/objectIs.js)です。参照が同一であれば true を返し、再レンダリングをスケジュールしません。
bailout が成立する4つの条件
この最適化が適用されるには、以下の4条件すべてを満たす必要があります。
| # | 条件 | 該当箇所 |
|---|---|---|
| 1 | レンダーフェーズ中でないこと | L3645 |
| 2 | 保留中の更新がないこと(fiber.lanes === NoLanes) |
L3649-3651 |
| 3 | reducer が存在すること | L3657 |
| 4 | Object.is(新しい値, 現在の値) === true |
L3672 |
今回のケースでは:
- ✅
useEffect内(コミットフェーズ) → レンダーフェーズ外 - ✅ 初回レンダリング完了後 → lanes は空
- ✅
useStateの内部 reducer は存在する - ✅ 同一参照のオブジェクトを渡している
4条件すべてが成立するため、bailout が発動します。
再レンダリングを発生させるには
スプレッド構文で新しいオブジェクトを生成して渡せば、Object.is が false を返すため再レンダリングが発生します。
useEffect(() => {
setState({ ...nested });
}, []);
この場合の出力は以下の通りです。
1
2
1
App が再レンダリングされ、A は通常のコンポーネントなので再描画されますが、B は React.memo でラップされているため props の浅い比較が行われます。ただし nested は毎回新しいオブジェクトが生成されるので… 🤔 これはまた別の話題ですね。
上級編:startTransition と setState の実行順序
function App() {
const [value, setValue] = useState("");
console.log(value)
function handleClick() {
startTransition(() => {
setValue(prev => `${prev}1`)
})
setValue(prev => `${prev}2`)
}
return (
<button onClick={handleClick}>
Pressed {value} times
</button>
);
}
ボタンを1回クリックしたとき、console.log は2回呼ばれます。その2つの出力値は?
選択肢
-
A.
"1","12" -
B.
"1","21" -
C.
"2","12" -
D.
"2","21"
答えを見る
C. "2", "12"
1回目は "2"(Sync レンダリング)、2回目は "12"(Transition レンダリング)。
解説:Lane による優先度制御と update の rebase
この問題を理解するには、React の Lane(レーン) による更新の優先度制御を知る必要があります。
2つの setState は異なる Lane に割り当てられる
handleClick() 内で2つの setValue が呼ばれますが、それぞれ異なる優先度の Lane が付与されます。
handleClick() {
startTransition(() => {
setValue(prev => `${prev}1`) ← TransitionLane(低優先度)
})
setValue(prev => `${prev}2`) ← SyncLane(高優先度)
}
requestUpdateLane() が呼び出し元のコンテキストに応じて Lane を決定します。startTransition 内では requestTransitionLane() が呼ばれ、低優先度の TransitionLane が割り当てられます。
Step 1: Sync レンダリング → "2"
React はまず高優先度の SyncLane を処理します。getHighestPriorityLanes() が SyncLane を最優先として選択するためです。
ReactFiberHooks.js の update ループ(L1358-1536)では、各 update の Lane が現在の renderLanes に含まれるかチェックします。
Update queue: baseState = ""
[1] TransitionLane: prev => `${prev}1` → renderLanes に含まれない → スキップ
[2] SyncLane: prev => `${prev}2` → renderLanes に含まれる → 適用
結果: "" → "2"
console.log("2")
ここで重要なのが baseState の扱いです。update がスキップされると、React はその 直前の state を baseState として記録 します(L1419-1421)。
// ReactFiberHooks.js L1406-1421
if (shouldSkipUpdate) {
// Priority is insufficient. Skip this update. If this is the first
// skipped update, the previous update/state is the new base
// update/state.
const clone: Update<S, A> = { /* ... */ };
if (newBaseQueueLast === null) {
newBaseQueueFirst = newBaseQueueLast = clone;
newBaseState = newState; // ← スキップ直前の state を baseState に
}
// ...
}
このレンダリングの結果、hook には以下が保存されます:
| フィールド | 値 | 説明 |
|---|---|---|
memoizedState |
"2" |
画面に表示される state |
baseState |
"" |
最初にスキップされた update の直前の state |
baseQueue |
[update1, update2] |
スキップされた update 以降の全 update |
Step 2: Transition レンダリング → "12"
Sync レンダリングの commit 後、保留中の TransitionLane を処理するレンダリングが始まります。
このとき、計算は memoizedState("2")からではなく、baseState("")から 開始されます(L1360: let newState = baseState)。そして baseQueue に保持されていた すべての update が queue の順序通りに再適用(rebase) されます。
baseState = ""
baseQueue:
[1] TransitionLane: prev => `${prev}1` → 適用: "" → "1"
[2] SyncLane: prev => `${prev}2` → 適用: "1" → "12"
結果: "" → "1" → "12"
console.log("12")
update がすべて処理され、スキップがなくなったので baseState と memoizedState は同じ "12" に収束します(L1532-1533)。
全体の流れ
初期 state: ""
[Sync レンダリング] renderLanes = SyncLane
update1 (Transition) → スキップ
update2 (Sync) → "" + "2" = "2"
→ console.log("2")
[Transition レンダリング] renderLanes = TransitionLane | SyncLane
update1 (Transition) → "" + "1" = "1" ← rebase
update2 (Sync) → "1" + "2" = "12" ← rebase
→ console.log("12")
なぜ "21" ではなく "12" なのか
setValue(prev => ${prev}2) は コード上は startTransition の後に書かれています が、React の update queue では startTransition 内の update が先にキューイングされます。Transition レンダリングでの rebase 時には queue の 挿入順 で処理されるため、1 → 2 の順になります。
優先度は「どの update を先に画面に反映するか」を制御しますが、最終的な state の計算では queue の順序 が尊重されます。
Discussion