React Fiber完全攻略
今回は、Reactの心臓部ともいえるFiberアーキテクチャについて話したい。
筆者が初めてReactに触れた頃、「Fiber」という言葉は、面接の定番質問くらいにしか認識していなかった。「Reactのレンダリング処理を作業単位に分割して実行する」という一行の定義を覚え、それがすべてだと思っていた。しかし実際にReactのソースコードを読み始めると、Fiberは単なる概念ではなく、Reactレンダリングのすべてを司る実行アーキテクチャだと気づいた。
初めてReactのソースコードを開いたときの衝撃は、今でも忘れられない。「これは……いったい何なんだ?」と思った。
この記事では、「Fiberとは何ですか?」という質問に「作業単位に分けて処理するものです」と答えるレベルを超えて、Fiberがなぜ誕生し、どのように設計され、その構造がReactのConcurrent Featuresをどのように可能にしているのかまで、深く掘り下げていく。
なぜFiberは登場したのか?
この問いに答えるには、まずFiber以前の世界、つまりReact 15まで使われていたStack Reconcilerがどのような問題を抱えていたのかを理解する必要がある。
Stack Reconcilerは、その名のとおり再帰呼び出しベースの差分調整エンジンだった。コンポーネントツリーを上から下へ再帰的に走査し、一度レンダリングを開始すると、ツリー全体を最後まで処理しなければ停止できなかった。これは、電話中に相手が話し終わるまで絶対に切れないような状況だ。(相手が3時間にわたる人生相談を始めたのに、途中で切れないと考えてみてほしい。恐ろしい。)
Stack Reconcilerには、具体的に次のような限界があった。
- レンダリング中に中断できない:ツリー全体を一度に処理する必要があるため、複雑なUIではメインスレッドが数十〜数百ミリ秒にわたって占有された
- 優先度という概念がない:ユーザーがボタンをクリックしても、バックグラウンドデータが更新されても、すべての更新が同じ方法で処理された
- アニメーションやジェスチャーへの対応が難しい:60fpsを維持するには1フレームあたり約16ms以内にすべての作業を終える必要があるが、再帰的レンダリングではそれを保証できなかった
- エラー発生時にアプリ全体が停止する:コンポーネントツリーのどこかでエラーが発生すると、アプリ全体が止まる問題があった
こうした限界を克服するため、Reactチームは作業を分割し、優先度を付け、必要に応じて中断・再開できる新しい実行モデルを検討した。その成果が、まさにReact Fiberである。
Andrew Clarkが執筆したreact-fiber-architectureには、この設計の核心となる思想がまとめられており、Fiberを理解するうえで最も重要な参考資料だ。(この文書を書いて間もなくReactチームに加わったようだ。)
Stack vs Fiber
では、Stack ReconcilerとFiber Reconcilerは、コードレベルでどのように異なるのだろうか?
再帰ベースのStack Reconciler
function renderComponent(component) {
const element = component.render();
element.props.children.forEach(child => renderComponent(child)); // 재귀 호출
}Stack方式では、子コンポーネントに出会うと即座に再帰呼び出しへ入る。この方式の問題は、JavaScriptのコールスタックに直接依存している点だ。再帰呼び出しが深くなるほどコールスタックにフレームが積まれ、そのすべてが解消されるまで、ブラウザーのメインスレッドはほかの処理を実行できない。
簡単にいえば、コールスタックが空になるまでブラウザーは身動きすら取れない状態になる。
上の動画では、Stack Reconcilerがレンダリングしている間、メインスレッドが完全にブロックされる様子を確認できる。
反復ベースのFiber Reconciler
Fiberは再帰を反復処理に置き換えた。コールスタックの代わりに、独自の仮想スタックをメモリ上に実装したのである。各Fiberノードが一つの「スタックフレーム」となり、これらのノードはJavaScriptオブジェクト(ヒープメモリ)として存在するため、いつでも中断し、後から続きを再開できる。
function performWork(deadline) {
while (nextUnitOfWork && deadline.timeRemaining() > 5) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
requestIdleCallback(performWork); // 나눠서 실행
}上のコードは、Fiber初期の概念モデルを示している。重要なのは、while ループ内で一度に一つの作業単位だけを処理し、時間が足りなくなればループを抜けてブラウザーに制御を返す点だ。
(初期にはrequestIdleCallbackを使う方式だったが、実際のReactはこれを使っていない。その理由は後で詳しく扱う。)
Fiber方式では、レンダリング中でもユーザーイベント(ボタンクリック、入力など)にすぐ反応できる。作業を細かく分割して実行するため、ブラウザーが息をつく余地が生まれる。
二つの方式の違いを直接体験したければ、**こちら**をクリックしてほしい。Stack ReconcilerとFiber Reconcilerの動作の違いを目で確認できる。
これこそ、Andrew Clarkが文書で強調したFiberの主要な目標だ。
- 作業を一時停止し、後から戻ってこられる
- 異なる種類の作業に優先度を付けられる
- 以前に完了した作業を再利用できる
- 不要になった作業を中止できる
Fiberノードの内部構造
ここまで読むと、一つの疑問が自然に浮かぶ。「では、Fiberノードの内部はどうなっているのか?」
Reactチームは、Fiberの内部実装に関する公式文書を別途提供していない。しかし、Andrew Clarkのreact-fiber-architecture文書と、実際のReactソースコード(ReactFiber.js)からその構造を把握できる。
筆者はFiberノードを作業指示書にたとえたい。工場で製品を組み立てるとき、各作業指示書には「この部品はどの種類か」「どの材料を使うか」「次にどの作業を行うか」「優先度はどれくらいか」が書かれている。Fiberノードも同じだ。
ReactElementとFiberNode
Fiberを理解するには、まずReactElementとFiberNodeを区別しなければならない。この二つはよく混同されるが、まったく別のものだ。
// ReactElement — React.createElement()가 반환하는 가벼운 객체
export interface ReactElement {
type: string | Function; // 문자열(HTML 태그) 또는 함수(컴포넌트)
props: {
[key: string]: any;
children: ReactElement[];
};
key: string | null;
ref: any;
_owner: FiberNode | null;
}ReactElementはUIの設計図にすぎない。「このコンポーネントをこのpropsでレンダリングしてほしい」という依頼書であり、実際のレンダリング処理やstateは含まれていない。
一方、FiberNodeは、この設計図を基にReactが内部で生成する実行時の作業単位だ。ReactElementにはないtag、stateNode、child/sibling/return、memoizedState、updateQueue、lanesといったフィールドがここに存在する。
ReactがReactElementのtypeを見てFiberNodeを生成するとき、tagの値が決まる。
typeがfunctionでprototype.isReactComponentを持つ場合 →tag = ClassComponent(1)typeがfunctionの場合 →tag = FunctionComponent(0)typeがstring("div"など)の場合 →tag = HostComponent(5)
tagはFiberNodeの種類を表す数値定数だ。ReactWorkTags.jsで定義されており、FunctionComponent(0)、ClassComponent(1)、HostRoot(3)、HostComponent(5)、HostText(6)など、25種類以上のtagが存在する。Reactはこのtag値を基に、beginWorkでどの処理を実行するかを決定する。
typeは差分調整で中心的な役割を果たす。Reactが前回のレンダリングのFiberと新しい要素を比較するとき、最初に確認するものがtypeだ。(この値はReactElementからFiberNodeへそのまま渡される。)
- 前回も
divで今回もdivなら、ReactはそのFiberノードを再利用し、propsだけを更新する - 前回は
divだったものが今回はspanに変わったなら、Reactは既存のFiberを破棄し、新しいFiberを生成する
keyもReactElementからFiberNodeへ渡される値で、主にリスト(配列)のレンダリング時に使われる。keyがないと、リスト項目の順序が変わったときに、どの項目がどこへ移動したのかをReactが正確に判断できない。その結果、不要なDOM操作が発生したり、コンポーネントの内部stateが意図せず維持または失われたりする可能性がある。
child, sibling, return
React Fiberが再帰ではなく反復を使える秘密が、ここにある。
function 부모() {
return [<자식1/>, <자식2/>];
}childは、コンポーネントのrenderが返した最初の子要素を指す。上の例では<자식1/>だ。siblingは、同じ親を持つ次の兄弟要素を意味する。<자식1/>のsiblingは<자식2/>である。returnは、現在のFiberノードの処理が終わった後に戻る親Fiberを指す。<자식1/>と<자식2/>のreturnは、どちらも부모だ。
この三つのフィールドが作る構造は、単方向連結リスト形式のツリーだ。一般的なツリー構造では子の配列(children[])を持たせるほうが直感的だが、Fiberは意図的にそれを避けた。
なぜだろうか。配列ベースの子構造では、走査のためにインデックスを管理する必要があり、途中で中断して再開するときには「どこまで処理したか」を別途追跡しなければならない。一方、連結リスト構造では、現在のノードへの参照さえ覚えておけば、いつでも続きから走査できる。これがFiberの中断と再開を自然に支える構造的基盤である。
Reactはこの構造を基に、深さ優先探索(DFS)の順序でノードを走査する。childに沿って下り(beginWork)、葉ノードに到達したらsiblingを確認し、兄弟がなければreturnに沿って上る(completeWork)方式だ。
pendingPropsとmemoizedProps
pendingPropsは、そのFiberが処理を開始する時点で渡された新しいpropsを意味し、memoizedPropsは、前回のレンダリングで処理が完了した以前のpropsを表す。
この二つの値が同じなら、Reactは「このコンポーネントには変更がない」と判断し、前回のレンダリング結果をそのまま再利用できる。これがbailout最適化の中心的な仕組みだ。
同様に、memoizedStateはそのFiberのフックのstateを保存し、updateQueueはまだ処理されていないstate更新(setState呼び出し)を連結リストとして管理する。
stateNode
stateNodeは、Fiberノードが指す実際のインスタンスを参照する。
- HostComponent(div、spanなど)の場合:実際のDOMノード
- ClassComponentの場合:クラスインスタンス
- HostRootの場合:FiberRootオブジェクト
このフィールドは、Fiberの仮想世界とブラウザーの実際のDOMをつなぐ橋の役割を果たす。
ダブルバッファリング:currentツリーとworkInProgressツリー
Fiberを理解するうえで欠かせない重要な概念が、**ダブルバッファリング(Double Buffering)**だ。
この概念を理解するために、ゲームグラフィックスを思い浮かべてみよう。ゲームで画面を描くとき、現在の画面にピクセルを直接描くと、描画途中のフレームがユーザーに見えてしまうティアリングが発生する。これを防ぐため、ゲームエンジンは二つのバッファを使う。一方のバッファに次のフレームを完全に描き、完成した時点で画面に表示するバッファを一度に切り替えるのだ。
React Fiberもまったく同じ戦略を使う。
currentFiber.alternate === workInProgressFiber;
workInProgressFiber.alternate === currentFiber;currentツリーは、現在画面に反映されているFiberツリーである。ユーザーが見ているUIの状態を表し、workInProgressツリーは次のレンダリングに向けてバックグラウンドで準備中のFiberツリーを表す。
二つのツリーはalternate プロパティで相互に参照する。すべての変更作業はworkInProgressツリーで行われ、作業が完了するとroot.current = finishedWorkという一行でツリーが切り替わる。以前のworkInProgressが新しいcurrentになり、以前のcurrentは次のレンダリングでworkInProgressとして再利用される。
function createWorkInProgress(current, pendingProps) {
let workInProgress = current.alternate;
if (workInProgress === null) {
// 최초 렌더: 새 Fiber를 생성하고 alternate를 연결
workInProgress = createFiber(current.tag, pendingProps, current.key, current.mode);
workInProgress.stateNode = current.stateNode; // DOM 노드는 공유!
workInProgress.alternate = current;
current.alternate = workInProgress;
} else {
// 재렌더: 기존 alternate를 재사용, effect만 초기화
workInProgress.pendingProps = pendingProps;
workInProgress.flags = NoFlags;
workInProgress.subtreeFlags = NoFlags;
workInProgress.deletions = null;
}
// lanes, child, memoizedState 등을 복사
workInProgress.childLanes = current.childLanes;
workInProgress.child = current.child;
// ...
}ここで重要な点を押さえよう。stateNode(実際のDOMノード)はcurrentとworkInProgressの間で共有される。毎回Fiberオブジェクトを新しく作るのではなく、既存のalternateを再利用し、変更されたフィールドだけを更新する。これにより、レンダリングのたびにガベージコレクション(GC)の負荷を増やすことなく、効率よくツリーを構築できる。
propsやstateに変更がなければどうなるだろうか。そのサブツリー全体を飛ばすbailout最適化が可能になる。ゲームのダブルバッファリングがフレーム単位の最適化なら、Fiberのダブルバッファリングはコンポーネント単位の最適化まで可能にする。
pendingWorkPriority => Lanes
ではFiberは、どのようにして「この作業のほうが重要だ」と判断するのだろうか?
expirationTimeの限界
初期のFiberはpendingWorkPriorityという数値ベースの優先度を使い、その後、expirationTimeという単一の数値へ発展した。期限が近いほど優先度が高いことを意味したが、この方式には根本的な限界があった。
単一の数値では、「この更新はAグループに属し、あの更新はBグループに属する」といった柔軟な分類ができなかったからだ。たとえばユーザー入力とTransitionの更新が同時に発生した場合、expirationTimeベースでは範囲の比較でしか分類できず、特定の更新だけを選択的に処理することに限界があった。
Lane
この問題を解決するため、Andrew ClarkがPR #18796で導入したのがLaneシステムだ。
Laneを理解するために、高速道路を思い浮かべてみよう。高速道路には複数の車線があり、それぞれ用途が異なる。第1車線は追い越し用(緊急)、第2車線は通常走行用、路肩は非常用だ。各車両(更新)は性質に合った車線へ割り当てられ、高速道路の管理システム(スケジューラー)が、どの車線の車両を先に通すかを決定する。
ReactのLaneも同じだ。各更新に**一つのビット(Lane)**を割り当て、ビット演算でグループを作成し比較する。
// 각 업데이트는 하나의 lane(단일 비트)을 가진다
const SyncLane = /* */ 0b0000000000000000000000000000010;
const InputContinuousLane = /* */ 0b0000000000000000000000000001000;
const DefaultLane = /* */ 0b0000000000000000000000000100000;
const TransitionLane1 = /* */ 0b0000000000000000000000100000000;
const IdleLane = /* */ 0b0001000000000000000000000000000;
// 배치(batch)는 여러 비트의 OR 조합이다
const SyncUpdateLanes = SyncLane | InputContinuousLane | DefaultLane;
// 특정 lane이 batch에 포함되는지 확인은 단순 비트 연산
const isIncluded = (lane & lanes) !== 0;合計31個のLaneが31ビット整数に収まるよう設計されている。これはV8エンジンの**SMI(Small Integer)**最適化を活用するためだ。31ビット以下の整数はV8でポインタータグ付き整数として処理され、ヒープ割り当てなしにスタック上で直接演算できる。主要なLaneの優先度は、ビットが低いほど高い。
この構造により、Reactは一度のビット演算で、どの作業を先に処理するかを判断できるようになった。getNextLanes() 関数はpendingLanesから最も優先度の高いLaneグループを選び、中断中のLaneを飛ばし、データを受信した再試行可能なLaneを優先するなど、高度なスケジューリングを可能にしている。
さらに、飢餓状態の防止のため、各Laneには有効期限が設定される。Sync/InputContinuousは250ms、Transitionは5,000msが経過するとexpiredLanesへ追加され、同期的に強制処理される。どれだけ優先度が低くても、永遠に無視されることはない。(優先度が低いというだけで永遠に無視されるなら、それは優先度システムではなく差別システムだ。)
Fiberの出力
ここまでFiberの構造を見てきたところで、新たな疑問が生まれる。これらのFiberノードは、どのように実際のDOMへ変換されるのだろうか?
出力とは、実際のDOMへ適用できる具体的なDOMノード情報を指す。ここには重要な区別がある。
// 사용자 정의 컴포넌트 — output 없음
function 아바타() {
return <img src="profile.jpg" />;
}
// 호스트 컴포넌트 — output 생성
<img src="profile.jpg" />
<div className="프로필" />実際のDOMノードを生成するのは、ホストコンポーネント(div、span、imgなど)だけだ。ブラウザーは<아바타/>が何なのか理解できない。ユーザー定義コンポーネントは抽象化された概念なので、最終的にホストコンポーネントへ分解されて初めてブラウザーが理解できる。
この過程をもう少し具体的に見てみよう。
function 프로필() {
return (
<div className="프로필">
<아바타 />
<유저정보 />
</div>
);
}
function 아바타() {
return <img src="profile.jpg" alt="프로필" />;
}
function 유저정보() {
return (
<div>
<h2>홍길동</h2>
<p>개발자</p>
</div>
);
}これらのコンポーネントが作るFiberツリーと出力の関係は、次のとおりだ。
프로필 (출력: 없음, 컴포넌트 함수)
│
└─► div.프로필 (출력: <div class="프로필">...</div>)
│
├─► 아바타 (출력: 없음, 컴포넌트 함수)
│ │
│ └─► img (출력: <img src="profile.jpg" alt="프로필">)
│
└─► 유저정보 (출력: 없음, 컴포넌트 함수)
│
└─► div (출력: <div>...</div>)
│
├─► h2 (출력: <h2>홍길동</h2>)
│
└─► p (출력: <p>개발자</p>)
出力の収集は下から上へ進む。まず葉にあたるホストノードでDOMが生成される。
// 호스트 컴포넌트들이 실제 DOM 정보 생성
img_fiber.output = createDOMElement('img', {
src: 'profile.jpg',
alt: '프로필'
});
h2_fiber.output = createDOMElement('h2', {}, '홍길동');
p_fiber.output = createDOMElement('p', {}, '개발자');次に、親のホストコンポーネントが子の出力を収集する。
// div 노드가 자식들의 출력을 수집
유저정보_div_fiber.output = createDOMElement('div', {}, [
h2_fiber.output, // <h2>홍길동</h2>
p_fiber.output // <p>개발자</p>
]);
// 최상위 div가 모든 자식 출력을 수집
프로필_div_fiber.output = createDOMElement('div', {className: '프로필'}, [
img_fiber.output, // <img src="profile.jpg" alt="프로필">
유저정보_div_fiber.output // <div><h2>홍길동</h2><p>개발자</p></div>
]);最後に、ユーザー定義コンポーネントは子の出力をそのまま上へ渡す。
// 사용자 정의 컴포넌트는 자식의 출력을 위로 전달
아바타_fiber.output = img_fiber.output;
유저정보_fiber.output = 유저정보_div_fiber.output;
프로필_fiber.output = 프로필_div_fiber.output;Fiberのスケジューリング
Fiberの主要な価値が「作業を分割できること」なら、実際にその「分割」を行う場所はどこだろうか。それがWork Loopだ。
Work Loop:Fiber走査の心臓部
Reactのレンダリングは、ReactFiberWorkLoop.jsで定義されたWork Loopから始まる。Reactは状況に応じて二種類のWork Loopを使う。
// 동기 렌더링: 중단 없이 모든 Fiber를 처리
function workLoopSync() {
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
// 동시성 렌더링: 시간 제한 내에서 작업을 나누어 처리
function workLoopConcurrent(nonIdle) {
if (workInProgress !== null) {
const yieldAfter = now() + (nonIdle ? 25 : 5);
do {
performUnitOfWork(workInProgress);
} while (workInProgress !== null && now() < yieldAfter);
}
}二つの関数の違いに注目してほしい。workLoopSyncはworkInProgressがnullになるまで無条件に回り続ける。一方、workLoopConcurrentには時間制限があり、時間を超えるとループを抜ける。
ここで興味深いのは、処理を譲る間隔の違いだ。TransitionやRetryのような非アイドル作業(ユーザーが知覚できる更新)は25ms間隔で制御を譲り、アイドル作業(ユーザーが何もしていないときに処理してもよい低優先度の作業)は5ms間隔で譲る。非アイドル作業に25msを与える理由は、意図的にアニメーションを約30fpsに制限し、トランジションのレンダリングがほかの作業を飢餓状態に陥らせるのを防ぐためだ。
performUnitOfWork
performUnitOfWorkは、一つのFiberノードを処理する関数だ。Fiber走査の核心が、この関数に含まれている。
function performUnitOfWork(unitOfWork) {
const current = unitOfWork.alternate;
const next = beginWork(current, unitOfWork, renderLanes);
unitOfWork.memoizedProps = unitOfWork.pendingProps;
if (next !== null) {
workInProgress = next;
} else {
completeUnitOfWork(unitOfWork);
}
}beginWorkは現在のノードを処理し、最初の子を返す。そしてpendingPropsをmemoizedPropsとして確定し、子があればその子へ、なければcompleteUnitOfWorkを呼び出す。
beginWork
beginWorkはFiberノードを上から下へ走査し、各ノードで必要な計算を行う関数だ。ReactFiberBeginWork.jsで定義され、内部ではFiberのtagに応じた巨大なswitch文で分岐する。
function beginWork(current, workInProgress, renderLanes) {
// bailout 체크: props와 context가 변경되지 않았다면 스킵
if (current !== null) {
const oldProps = current.memoizedProps;
const newProps = workInProgress.pendingProps;
if (oldProps === newProps && !hasContextChanged()) {
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
}
switch (workInProgress.tag) {
case FunctionComponent:
return updateFunctionComponent(current, workInProgress, ...);
case ClassComponent:
return updateClassComponent(current, workInProgress, ...);
case HostComponent:
return updateHostComponent(current, workInProgress, ...);
case SuspenseComponent:
return updateSuspenseComponent(current, workInProgress, ...);
// ... 약 25가지 이상의 케이스
}
}重要なのは、最上部のbailout判定だ。propsとcontextが以前と同じなら、bailoutOnAlreadyFinishedWorkでそのサブツリー全体を飛ばす。これはReactのパフォーマンス最適化において最も重要な経路の一つだ。
beginWorkの返り値は、最初の子Fiberである。子があればその子が次のworkInProgressになり、なければ(null)completeUnitOfWorkへ入る。
completeWork
completeWorkは葉ノードから始まり、親方向へ上りながら作業を完了する関数だ。
function completeUnitOfWork(unitOfWork) {
let completedWork = unitOfWork;
do {
// 1. completeWork로 현재 노드의 작업 마무리 (DOM 생성 등)
completeWork(current, completedWork, renderLanes);
// 2. 형제가 있으면 형제로 이동 (다시 beginWork 시작)
const siblingFiber = completedWork.sibling;
if (siblingFiber !== null) {
workInProgress = siblingFiber;
return;
}
// 3. 형제가 없으면 부모로 올라감
completedWork = completedWork.return;
workInProgress = completedWork;
} while (completedWork !== null);
}completeWorkで実行される主な作業は次のとおりだ。
- HostComponentの場合:実際のDOMノードを生成(
createInstance)し、子DOMを追加する。すでにDOMが存在する場合は、変更されたpropsを収集してupdateQueueへ保存する。 bubbleProperties():子のflagsをsubtreeFlagsへ集約する。この情報は、コミットフェーズでサブツリーをスキップする最適化に使われる。
走査をまとめると、次のようになる。childに沿って下り(beginWork)→ 葉で完了した後siblingへ移動 → 兄弟がなければreturnに沿って上る(completeWork)。これがFiberの深さ優先探索の順序である。
requestIdleCallbackを捨てた理由
先ほどFiberの概念モデルではrequestIdleCallbackを使うコードを示したが、実際のReactはこれを使っていない。その理由は明確だ。
- 呼び出し頻度が低すぎる:本当に「アイドル時間(ブラウザーにすることがない時間)」にしか呼ばれないため、負荷の高いページではReactの作業がいつまでも遅延する可能性がある。Dan Abramovも「requestIdleCallback is called too infrequently to be useful for scheduling React work」と述べている。
- ブラウザー互換性の問題:Safariは長い間これを実装しておらず、ブラウザーごとに動作も異なっていた。
- 20msの上限:アイドル時間の期限には上限があり、Reactが求めるレベルの予測可能なタイミング制御ができなかった。
次にrequestAnimationFrameとフレーム予算の推定を組み合わせる方式も試したが、Reactの作業を垂直同期(モニターが垂直走査を完了する時点に合わせてフレーム出力を同期する技術)の周期に合わせる必要はないとの判断から、これも廃止された。
MessageChannel
最終的にReactはMessageChannelを選んだ。
if (typeof MessageChannel !== 'undefined') {
const channel = new MessageChannel();
channel.port1.onmessage = performWorkUntilDeadline;
schedulePerformWorkUntilDeadline = () => channel.port2.postMessage(null);
} else {
schedulePerformWorkUntilDeadline = () => setTimeout(performWorkUntilDeadline, 0);
}なぜsetTimeoutではなくMessageChannelなのか。HTML仕様により、setTimeoutは5回以上ネストすると最低4msの遅延が強制される。一方、MessageChannelはこの制限なしに、イベントループの次のティックで即座にマクロタスクとして実行される。5ms単位で作業を分割するFiberにとって、4msの人為的な遅延は致命的だからだ。
(5msのうち4msが待ち時間なら、実際に働く時間は1msしかない。これはワークライフバランスではなく、ただのライフだ。)
ReactのSchedulerパッケージは、内部で二つの最小ヒープを管理する。
timerQueue (대기실) taskQueue (실행 대기열)
┌──────────────────┐ ┌──────────────────┐
│ 아직 시작 시간이 │ startTime │ 지금 실행 가능한 │
│ 안 된 태스크들 │ ──경과 시──→ │ 태스크들 │
│ │ │ │
│ 정렬: startTime │ │ 정렬: expiration │
│ (빠른 순) │ │ Time (임박한 순) │
└──────────────────┘ └──────────────────┘
taskQueueは「今すぐ実行できる」タスクのキューだ。expirationTime(= startTime + timeout)が小さいほど、つまり期限が近いほど先に実行される。timerQueueは「まだ実行時刻になっていない」タスクの待合室だ。現在時刻がstartTimeを超えた瞬間、taskQueueへ移動する。
では、expirationTimeを決めるタイムアウトはどのように定まるのだろうか。各更新には、優先度レベルに応じて固有のタイムアウトが設定される。
우선순위 timeout 만료까지 예시
─────────────────────────────────────────────────────────
Immediate -1ms 즉시 만료 flushSync
UserBlocking 250ms 0.25초 클릭, 입력
Normal 5,000ms 5초 일반 setState
Low 10,000ms 10초 startTransition
Idle ~1,073,741,823ms ~12.4일 오프스크린 렌더링
Immediateは生成された瞬間に期限切れになる。taskQueueへ入ると同時に最優先で実行されるのだ。(生まれた瞬間に期限切れとは、少し物悲しい運命ではある。)UserBlockingの250msは、人が「反応が遅い」と感じる閾値(100〜300ms)に合わせた値だ。クリックして0.25秒以内に反応がなければ、ユーザーは不快に感じる。Normalの5秒は余裕があるように見えるが、これは「最悪の場合でも必ず処理する」という保証だ。実際には、先行する作業が終わればすぐに実行される。Idleの約12.4日は、事実上無限である。ほかのすべての作業が終わって初めて実行される。(ブラウザーを閉じずに12日間使い続けることはほぼないので、無限と考えて差し支えない。)
これらのタイムアウト値は、同時に飢餓状態を防ぐ仕組みでもある。どれほど優先度が低くても、タイムアウトを過ぎれば期限切れ状態となり、強制的に実行される。高優先度の作業が絶えず入ってきても、低優先度の作業が永遠に無視されることはない。
SchedulerのshouldYieldToHost()は、作業開始後の経過時間がframeInterval(既定値は5ms、SchedulerFeatureFlags.jsで定義)を超えたか確認し、メインスレッドへ制御を返すかどうかを判断する。
レンダーフェーズとコミットフェーズ
ここまでFiberの構造とスケジューリングを見てきた。ここで、これらすべてがどのように組み合わさり、実際のUI更新が行われるのか、全体の流れを整理しよう。
Fiberは内部で、レンダーフェーズとコミットフェーズという二つの段階を経る。この分離は、Reactの並行処理モデルを可能にする中心的な設計だ。Fiberの動作フローを直接確認したければ、下の画像をクリックしてほしい。
レンダーフェーズ
レンダーフェーズは、UIにどのような変更が必要かを計算する段階だ。この段階では、実際のDOMには何の影響も与えない。そして最も重要な特徴は、非同期的に中断・再開できることだ。
この段階は、先ほど見たbeginWorkとcompleteWorkを中心に動作する。
**beginWork(fiber)**では、各Fiberのtype(FunctionComponent、ClassComponent、HostComponentなど)に応じて適切な処理を実行する。そして子Fiberノードを生成し、接続する。propsが以前と同じなら、メモ化によってスキップできる(bailout)。
**completeWork(fiber)**では、DOM生成処理やエフェクト情報を準備する。そしてbubbleProperties()を通じて子のflagsをsubtreeFlagsへ集約し、親方向へ上りながら情報を補完する。
この段階ではDOMを直接変更しないため、いつでも作業を中断し、後から再開しても、不完全なUIがユーザーに表示されることはない。これがConcurrent Modeの基盤である。
subtreeFlags
レンダーフェーズでは、各Fiberに必要な副作用がビットフラグとして記録される。ReactFiberFlags.jsで定義されている主なフラグを見てみよう。
Placement:新しいノードをDOMへ挿入Update:DOMプロパティの更新が必要ChildDeletion:子ノードの削除が必要Ref:refの接続・解除が必要Passive:useEffectのコールバック実行が必要Snapshot:getSnapshotBeforeUpdateを実行Callback:ライフサイクルのコールバックを実行
以前のReact(およそ16まで)では、firstEffect → nextEffect → lastEffectでつながる連結リストを使い、副作用を持つFiberだけを集めていた。しかしこの方式には、アンマウントされたFiberへの参照が残ってメモリリークを起こす問題があり、Suspenseのような新しいパターンを効率的に処理することも難しかった。
React 17からは、このエフェクトリストを削除し、subtreeFlags方式へ移行した(PR #19381)。completeWorkの段階でbubbleProperties()が子のflagsを親へ集約する。
function bubbleProperties(completedWork) {
let subtreeFlags = NoFlags;
let child = completedWork.child;
while (child !== null) {
subtreeFlags |= child.subtreeFlags;
subtreeFlags |= child.flags;
child = child.sibling;
}
completedWork.subtreeFlags |= subtreeFlags;
}この構造の最大の利点は、コミットフェーズでサブツリー全体をスキップできることだ。あるFiberがsubtreeFlags & MutationMask === NoFlagsなら、そのサブツリーにはDOM変更が必要なノードが一つもないため、全体を飛ばせる。以前の連結リスト方式では不可能だった最適化である。
コミットフェーズ
コミットフェーズは、レンダーフェーズで計算した変更内容を実際のDOMへ反映する段階だ。この段階は常に同期的に実行され、一度始まると最後まで中断されない。ユーザーが更新途中のUIを見ることを防ぐためである。
コミットフェーズは内部で、次のような詳細な順序で動作する。
- 変更前フェーズ:
commitBeforeMutationEffects()- DOMが変更される前に、現在のDOMの状態を読み取る。
getSnapshotBeforeUpdateのライフサイクルはここで実行される。この時点ではcurrentツリーがまだ画面の状態を表しているため、DOMのスクロール位置やサイズなどの情報を安全に取得できる。
- DOMが変更される前に、現在のDOMの状態を読み取る。
- 変更フェーズ:
commitMutationEffects()- 実際のDOM操作が行われる段階だ。新しいノードの挿入、既存ノードの変更、不要なノードの削除がすべてここで発生する。
componentWillUnmountもこの時点で実行される。まだcurrentが以前のツリーを指しているため、以前の状態を読めるからだ。
- 実際のDOM操作が行われる段階だ。新しいノードの挿入、既存ノードの変更、不要なノードの削除がすべてここで発生する。
- ツリーの切り替え:
root.current = finishedWork- ダブルバッファリングの核心だ。workInProgressツリーがcurrentツリーへ昇格する。この切り替えが変更フェーズ後、レイアウトフェーズ前に行われる理由は重要だ。
componentWillUnmountは以前のツリーを読む必要があるため変更フェーズで実行しなければならず、componentDidMount/componentDidUpdateは新しいツリーを読む必要があるためレイアウトフェーズで実行しなければならない。
- ダブルバッファリングの核心だ。workInProgressツリーがcurrentツリーへ昇格する。この切り替えが変更フェーズ後、レイアウトフェーズ前に行われる理由は重要だ。
- レイアウトフェーズ:
commitLayoutEffects()- DOMの変更が完了した後、新しいDOMの状態を基にする処理が実行される。
componentDidMount、componentDidUpdateを実行useLayoutEffectのコールバックを実行- この時点では
currentがすでに新しいツリーを指しているため、DOMを読むと更新後の値を取得できる
- DOMの変更が完了した後、新しいDOMの状態を基にする処理が実行される。
- パッシブエフェクト(非同期)
useEffectのクリーンアップとセットアップは別途スケジュールされ、非同期的に実行される。これらはDOM変更に依存しない副作用(データ取得、イベント購読など)を処理するため、同期的に実行する必要がない。非同期で処理することで、ブラウザーが先に画面を描画できるよう制御を譲る。
Concurrent FeaturesとFiber
ここまで見てきたFiberのすべての設計(ダブルバッファリング、Laneベースの優先度、中断可能なWork Loop)が、実際にどのようなユーザー体験を可能にするのか、React 18以降のConcurrent Featuresを通じて確認してみよう。
useTransition
startTransition(() => setState(...))を呼び出すと、その更新にはTransitionLaneが割り当てられる。14個のTransitionLaneがラウンドロビン(順番に一つずつ割り当てる方式)で割り当てられ、衝突を防ぐ。
TransitionLaneはSyncLaneやDefaultLaneより優先度が低いため、ユーザー入力のような緊急の更新が入ると、トランジションのレンダリングを中断して緊急の更新を先に処理できる。その間、画面にはcurrentツリー(以前の状態)が維持され、トランジションはworkInProgressツリーでバックグラウンド処理される。
ここでダブルバッファリングの価値が光る。中断されたトランジションのレンダリングはworkInProgressツリーにしか影響せず、ユーザーが見る画面(currentツリー)はまったく損なわれない。
isPending フラグは、このトランジションがまだ完了していないことを示し、ローディング表示などの処理を可能にする。
useDeferredValue
useDeferredValue(value)は、初回レンダリングでは渡されたvalueをそのまま返す。それ以降のレンダリングで現在のレンダリングが緊急の場合、以前に記憶した値を返し、TransitionLaneで新しいレンダリングをスケジュールする。遅延されたレンダリングは、Transitionと同じく中断できる。
概念的にはstartTransitionと似ているが、更新をディスパッチする側ではなく、値を受け取る側で適用する点が異なる。検索入力欄の文字はすぐ反映しつつ、検索結果リストのレンダリングを遅らせるのが代表的な利用例だ。
Suspense
コンポーネントが<Suspense>内でPromiseをthrowすると、throwExceptionがそれをcatchし、そのFiberをIncompleteとしてマークする。そしてreturnチェーンを上りながら最も近いSuspense境界を探し、その境界がフォールバックUIを表示するよう切り替える。Promiseがresolveすると、markRootPingedで該当Laneに再試行の印を付け、Reactが中断中のサブツリーを再びレンダリングする。
Concurrent Modeでは、中断中のコンポーネントの兄弟ノードを続けてレンダリングできるため、一つのデータ要求がツリー全体のレンダリングをブロックしない。これが可能なのは、Fiberの連結リスト構造によりsiblingへ自由に移動できるからだ。
Streaming SSRとSelective Hydration
React 18のrenderToPipeableStreamはSuspense境界を活用する。
- サーバー:Suspense境界が中断すると、フォールバックHTMLを先に送信し、データの準備ができたら後から
<script>タグで実際の内容をストリーミングする - クライアント(Selective Hydration):各Suspense境界を独立してハイドレーションできる。ユーザーがまだハイドレーションされていない領域をクリックすると、
SelectiveHydrationLaneを通じてその境界のハイドレーションを優先的に処理してからイベントをディスパッチする
これらすべてが可能なのは、各Suspense境界が独立してスケジュール可能なFiberノードだからだ。結局のところ、Fiberアーキテクチャの「作業を分割し、優先度を付け、中断・再開できる」という中心的な設計が、こうした機能の土台となっている。
おわりに
この記事の内容を一文でまとめると、React Fiberは、再帰を反復へ変え、コールスタックをヒープへ移すことで、レンダリングを中断・再開できるようにしたアーキテクチャである。
そのために、連結リストベースのツリー構造、ダブルバッファリング、Laneベースの優先度システム、MessageChannelベースのスケジューラーなど、数多くの精巧な設計が組み合わされている。そしてこれらすべては、最終的にユーザーが感じるUIの応答性を最大化することという目標へ向かっている。
もちろん、Fiberの内部実装はReactのバージョンが上がるたびに変化を続けており、この記事で扱った内容も、ある時点のスナップショットにすぎない。しかし、「作業を分割し、優先度を付け、中断し、再開できる」というFiberの中心的な思想は、今後も変わらないと考えている。
この記事を通じて、React Fiberが単なる面接用キーワードではなく、Reactのすべての機能を支える実行アーキテクチャだということが伝われば幸いだ。唯一の正解はないが、読者の皆さんにもソースコードを直接読み、それぞれの理解を築いていってほしい。
出典
참고 자료
📚関連記事
Toss Frontend Fundamentals 模擬試験 第2回のリファクタリングを終えて
2026/3/28 · 24 min read
今回の記事では、Toss Frontend Fundamentals 模擬試験の第2回に参加し、取り組んだリファクタリングについて振り返ってみたい。 以前からコードレビューやリファクタリングに関心があった筆者は、Tossが公開した「Frontend Fundamentals 模擬試験」という興味深い形式の課題に取り組むことになった。課題は、与えられた会議室予約アプリをリファクタリングするというもの...
queryKey
2026/1/4 · 43 min read
今回は、TanStack Query の queryKeyについて掘り下げてみたい。 筆者は実務で TanStack Query を使う中で、queryKey の管理方法を何度も作り直してきた。最初はコンポーネント内に ['user', userId] のような配列を直接書いていたが、無効化のたびに同じキーを複数箇所へ記述するうちにタイプミスが増え、QUERYKEYS のような定数オブジェクトへ移...
エラーハンドリング
2025/11/17 · 44 min read
今回は、フロントエンドでエラーをどう捉えるかについて考えてみたい。 筆者は実務でエラーハンドリングを書くたび、どこか釈然としない感覚を抱くことが多かった。あるエラーは try/catch で捉え、別のエラーは ErrorBoundary が捉え、さらに別のエラーは TanStack Query の onError が捉える。それぞれの守備範囲は微妙に重なったり、ずれたりする。その結果、エラーが漏れ...
