До версії 16 ядро React використовувало рекурсивний механізм порівняння віртуальних дерев — Stack Reconciler. При будь-якому оновленні стану рушій синхронно обходив усе дерево компонентів згори донизу через стандартний стек викликів JavaScript (Call Stack).
Якщо дерево містило тисячі вузлів, операція блокувала основний потік браузера на 50–200 мс. У цей період браузер не міг реагувати на введення користувача, оновлювати CSS-анімації чи виконувати відмальовування кадрів, що спричиняло помітні затримки та випадання кадрів (Frame Drops).
Архітектура React Fiber повністю змінила цю парадигму. Замість неявного стека викликів рушія Fiber реалізує віртуальний стек у купі (heap-allocated virtual stack frame), де кожен елемент дерева представлений структурою даних FiberNode. Це дозволило:
На відміну від класичного DOM-дерева з масивами нащадків (children: [...]), внутрішній граф Fiber являє собою структуру зв'язного списку: кожен вузол знає лише свою першу дитину (child), наступного сусіда (sibling) та батьківський вузол (return).
[ Current Tree (Відображається в DOM) ] [ WorkInProgress Tree (Збирається у фоні) ]
FiberRoot FiberRoot
│ │
(current pointer) (workInProgress)
▼ ▼
[FiberNode: App] <====== alternate ======> [FiberNode: App]
/ ▲ / ▲
child/ \return child/ \return
▼ \ ▼ \
[FiberNode: Header] \ [FiberNode: Header] \
│ \ │ \
sibling\ \ sibling\ \
▼ \ ▼ \
[FiberNode: Main] ─────┘ [FiberNode: Main] ─────┘Така структура надає фундаментальну інженерну перевагу: граф обходиться в плоскому циклі while без рекурсії. У будь-який момент часу рантайм може зберегти вказівник на поточну ноду, звільнити потік браузеру для відмальовування кадру, а потім відновити обчислення саме з перерваного місця.
Ядро React підтримує в оперативній пам'яті два паралельні дерева:
workInProgress дерево за одну атомарну операцію FiberRoot.current = workInProgress зі складністю O(1).Подвійна буферизація гарантує, що користувач ніколи не побачить напівекранний або зламаний інтерфейс. Якщо рендеринг важкого піддерева переривається помилкою або витісняється пріоритетнішим введенням, недозібране дерево workInProgress просто відкидається без шкоди для відображуваного екрана.
Користувач ініціює дію. Планувальник генерує бітову маску події (наприклад, SyncLane або TransitionLane) і позначає шлях від цільового вузла до кореня FiberRoot через childLanes.
Створюється або переініціалізується вузол workInProgress Root через властивість alternate, копіюючи вхідні параметри поточного змонтованого дерева.
Обхід графа згори донизу. Виклик функцій компонентів, обчислення нового стану, перевірка необхідності оптимізації (bailout) та запуск дифінгу дітей.
Створення та налаштування інстансів DOM-вузлів у пам'яті, підключення пропсів, збирання черг ефектів та спливання бітової маски subtreeFlags.
Синхронне застосування мутацій до DOM: виклик деструкторів useLayoutEffect, маніпуляція DOM-вузлами, атомарне перемикання посилання FiberRoot.current та запуск пасивних useEffect.
Чому ядро React Fiber відмовилося від класичного дерева з масивами нащадків (children: [...]) на користь зв'язних списків (child, sibling, return)?
Рендеринг у React суворо поділений на дві концептуальні фази: Render Phase (асинхронна, переривана, вільна від побічних ефектів) та Commit Phase (синхронна, непереривана, що застосовує зміни до DOM).
Генерація бітової маски події (наприклад, SyncLane для введення або TransitionLane для фільтра) та пометка шляху від цільового вузла до кореня FiberRoot через childLanes.
Виконання дискретних задач performUnitOfWork. Спуск донизу через beginWork та підйом через completeWork з контролем дедлайну shouldYield().
Порівняння поточного FiberNode та нового JSX елемента. Визначення прапорців побічних ефектів (Placement, Update, ChildDeletion).
Синхронне застосування мутацій: виклик деструкторів useLayoutEffect, мутація DOM-вузлів, перемикання вказівника current, запуск useEffect.
Кожен вузол FiberNode створюється фабричною функцією ядра. Нижче наведено суворе типізування ключових полів структури в React 19:
export type WorkTag =
| 0 // FunctionComponent
| 1 // ClassComponent
| 2 // IndeterminateComponent
| 3 // HostRoot (Root node)
| 5 // HostComponent (<div>, <span>)
| 6 // HostText
| 11 // ForwardRef
| 13 // SuspenseComponent
| 24; // CacheComponent
export type Flags = number; // Бітова маска мутацій (Placement, Update, Deletion)
export type Lane = number; // 31-бітна маска пріоритету
export type Lanes = number;
export interface FiberNode {
// Ідентифікація та дескриптор
tag: WorkTag;
key: null | string;
elementType: unknown;
type: unknown;
stateNode: unknown; // Посилання на реальний DOM-вузол або інстанс компонента
// Вказівники зв'язного списку графа
return: FiberNode | null; // Батьківський вузол (куди піднімаємося при завершенні)
child: FiberNode | null; // Перший дочірній вузол
sibling: FiberNode | null; // Наступний сусідній вузол
index: number;
// Вхідні та збережені дані
pendingProps: Record<string, unknown>; // Пропси для поточної обробки
memoizedProps: Record<string, unknown>; // Пропси, застосовані в попередньому рендері
memoizedState: unknown; // Зв'язний список хуків (Hook linked list)
updateQueue: unknown; // Черга оновлень стану
// Пріоритети та ефекти
flags: Flags; // Локальні сайд-ефекти (мутації) даного вузла
subtreeFlags: Flags; // Агреговані ефекти дочірнього піддерева
deletions: Array<FiberNode> | null; // Дочірні вузли, намічені до видалення
lanes: Lanes; // Пріоритети очікуваних оновлень даного вузла
childLanes: Lanes; // Пріоритети очікуваних оновлень у піддереві
// Механізм двобуферності
alternate: FiberNode | null; // Вказівник на дзеркальну ноду (current <-> workInProgress)
}Виконання фази рендерингу координується функцією workLoopConcurrent. Метод shouldYield() спирається на планувальник Scheduler, який використовує нативний MessageChannel:
import { shouldYield } from './schedulerHostConfig';
let workInProgress: FiberNode | null = null;
let renderLanes: Lanes = 0;
export function workLoopConcurrent(): void {
// Цикл виконується доти, доки є завдання і браузер не вимагає повернути керування
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
function performUnitOfWork(unitOfWork: FiberNode): void {
const current = unitOfWork.alternate;
// 1. Фаза спуску: виклик функціонального компонента, дифінг дітей
let next: FiberNode | null = beginWork(current, unitOfWork, renderLanes);
unitOfWork.memoizedProps = unitOfWork.pendingProps;
if (next === null) {
// 2. Якщо дочірніх вузлів немає, переходимо до фази підйому
completeUnitOfWork(unitOfWork);
} else {
workInProgress = next;
}
}
function completeUnitOfWork(unitOfWork: FiberNode): void {
let completedWork: FiberNode | null = unitOfWork;
do {
const current = completedWork.alternate;
const returnFiber: FiberNode | null = completedWork.return;
// Збирання DOM-властивостей, агрегація subtreeFlags знизу вгору
completeWork(current, completedWork, renderLanes);
const siblingFiber: FiberNode | null = completedWork.sibling;
if (siblingFiber !== null) {
// Якщо є сусід, перемикаємо фокус на нього для спуску
workInProgress = siblingFiber;
return;
}
// Інакше піднімаємося до батька
completedWork = returnFiber;
workInProgress = completedWork;
} while (completedWork !== null);
}На відміну від ранньої концепції expirationTime, де пріоритет був одновимірним числом (що спричиняло блокування незалежних завдань), Lanes являють собою бінарні доріжки. У 32-бітному числі виділяється 31 смуга:
export const TotalLanes = 31;
export const NoLanes: Lanes = 0b0000000000000000000000000000000;
export const NoLane: Lane = 0b0000000000000000000000000000000;
// Синхронні блокуючі смуги (дискретне введення: клік, клавіатура)
export const SyncLane: Lane = 0b0000000000000000000000000000001;
// Безперервне введення (скрол, рух курсору)
export const InputContinuousLane: Lane = 0b0000000000000000000000000000010;
// Стандартні оновлення за замовчуванням (setState, відповіді мережі)
export const DefaultLane: Lane = 0b0000000000000000000000000010000;
// Переходи Transition (startTransition, useDeferredValue)
export const TransitionLanes: Lanes = 0b0000000011111111111111100000000;
export const TransitionLane1: Lane = 0b0000000000000000000000100000000;
// Фонові смуги очікування
export const IdleLanes: Lanes = 0b0110000000000000000000000000000;
/**
* Отримання найвищого пріоритету (крайній правий встановлений біт)
* Бітова магія: x & -x повертає найменший значущий біт у додатковому коді
*/
export function getHighestPriorityLane(lanes: Lanes): Lane {
return lanes & -lanes;
}
/**
* Перевірка перетину смуг
*/
export function isSubsetOfLanes(set: Lanes, subset: Lanes | Lane): boolean {
return (set & subset) === subset;
}
/**
* Об'єднання смуг
*/
export function mergeLanes(a: Lanes | Lane, b: Lanes | Lane): Lanes {
return a | b;
}
/**
* Видалення оброблених смуг
*/
export function removeLanes(set: Lanes, subset: Lanes | Lane): Lanes {
return set & ~subset;
}Вираз lanes & -lanes використовує представлення від'ємних чисел у додатковому коді (Two's Complement). Число -lanes математично еквівалентне ~lanes + 1. Побітова операція AND обнуляє всі біти, крім молодшого одиничного біта (Least Significant Bit), за один процесорний такт, дозволяючи планувальнику миттєво знаходити найтерміновіший Lane.
React вирішує фундаментальну задачу порівняння двох списків дітей за лінійний час O(N) замість класичного O(N^3) за допомогою двох евристичних проходів:
key). При першому ж розходженні прохід переривається.Map<Key | Index, FiberNode>. Нові елементи шукаються в цій карті за O(1). Якщо вузол знайдено, він перевикористовується з прапорцем Placement, а видалені вузли позначаються прапорцем ChildDeletion.interface ChildElement {
key: string | null;
type: unknown;
props: Record<string, unknown>;
}
export function reconcileChildrenArray(
returnFiber: FiberNode,
currentFirstChild: FiberNode | null,
newChildren: ChildElement[],
lanes: Lanes
): FiberNode | null {
let resultingFirstChild: FiberNode | null = null;
let previousNewFiber: FiberNode | null = null;
let oldFiber = currentFirstChild;
let nextOldFiber: FiberNode | null = null;
let lastPlacedIndex = 0;
let newIdx = 0;
// ПРОХІД 1: Йдемо по порядку, доки збігаються ключі
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
if (oldFiber.index > newIdx) {
nextOldFiber = oldFiber;
oldFiber = null;
} else {
nextOldFiber = oldFiber.sibling;
}
const newChild = newChildren[newIdx];
// Якщо ключ змінився, перший прохід завершується достроково
if (oldFiber.key !== newChild.key) {
if (oldFiber === null) {
oldFiber = nextOldFiber;
}
break;
}
const matchedFiber = updateSlot(returnFiber, oldFiber, newChild, lanes);
if (matchedFiber === null) {
break;
}
lastPlacedIndex = placeChild(matchedFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = matchedFiber;
} else {
previousNewFiber.sibling = matchedFiber;
}
previousNewFiber = matchedFiber;
oldFiber = nextOldFiber;
}
// Якщо нові елементи закінчилися, видаляємо залишкові старі
if (newIdx === newChildren.length) {
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
// ПРОХІД 2: Якщо старі ноди залишилися, поміщаємо їх у Map для O(1) пошуку
if (oldFiber !== null) {
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
for (; newIdx < newChildren.length; newIdx++) {
const newChild = newChildren[newIdx];
const existingFiber = existingChildren.get(
newChild.key !== null ? newChild.key : newIdx
) || null;
const newFiber = updateFromMap(existingFiber, returnFiber, newChild, lanes);
if (newFiber !== null) {
if (existingFiber !== null) {
existingChildren.delete(newChild.key !== null ? newChild.key : newIdx);
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
}
// Усі залишкові в Map ноди позначаються прапорцем ChildDeletion
existingChildren.forEach(child => deleteChild(returnFiber, child));
}
return resultingFirstChild;
}
function placeChild(newFiber: FiberNode, lastPlacedIndex: number, newIndex: number): number {
newFiber.index = newIndex;
const current = newFiber.alternate;
if (current !== null) {
const oldIndex = current.index;
if (oldIndex < lastPlacedIndex) {
// Елемент змістився вправо відносно свого попереднього оточення
newFiber.flags |= 0b0000000000000000000000000000010; // Placement
return lastPlacedIndex;
}
return oldIndex;
}
// Новий вузол, що вставляється вперше
newFiber.flags |= 0b0000000000000000000000000000010;
return lastPlacedIndex;
}
function updateSlot(
returnFiber: FiberNode,
oldFiber: FiberNode,
newChild: ChildElement,
lanes: Lanes
): FiberNode | null {
if (oldFiber.key === newChild.key && oldFiber.elementType === newChild.type) {
return useFiber(oldFiber, newChild.props);
}
return null;
}
function updateFromMap(
existingFiber: FiberNode | null,
returnFiber: FiberNode,
newChild: ChildElement,
lanes: Lanes
): FiberNode | null {
if (existingFiber !== null && existingFiber.elementType === newChild.type) {
return useFiber(existingFiber, newChild.props);
}
return createFiberFromElement(newChild, lanes);
}
function mapRemainingChildren(
returnFiber: FiberNode,
currentFirstChild: FiberNode
): Map<string | number, FiberNode> {
const existingChildren = new Map<string | number, FiberNode>();
let existing: FiberNode | null = currentFirstChild;
while (existing !== null) {
const key = existing.key !== null ? existing.key : existing.index;
existingChildren.set(key, existing);
existing = existing.sibling;
}
return existingChildren;
}
function useFiber(fiber: FiberNode, pendingProps: Record<string, unknown>): FiberNode {
const clone = fiber.alternate || { ...fiber };
clone.pendingProps = pendingProps;
return clone;
}
function createFiberFromElement(child: ChildElement, lanes: Lanes): FiberNode {
return {
tag: 0,
key: child.key,
elementType: child.type,
type: child.type,
stateNode: null,
return: null,
child: null,
sibling: null,
index: 0,
pendingProps: child.props,
memoizedProps: {},
memoizedState: null,
updateQueue: null,
flags: 0b0000000000000000000000000000010,
subtreeFlags: 0,
deletions: null,
lanes,
childLanes: 0,
alternate: null
};
}
function deleteChild(returnFiber: FiberNode, childToDelete: FiberNode): void {
returnFiber.flags |= 0b0000000000000000000000000010000; // ChildDeletion
if (returnFiber.deletions === null) {
returnFiber.deletions = [childToDelete];
} else {
returnFiber.deletions.push(childToDelete);
}
}
function deleteRemainingChildren(returnFiber: FiberNode, currentFirstChild: FiberNode | null): void {
let child: FiberNode | null = currentFirstChild;
while (child !== null) {
deleteChild(returnFiber, child);
child = child.sibling;
}
}Що відбувається всередині алгоритму reconcileChildrenArray у другому проході, якщо в елемента масиву змінилася позиція з індексу 5 на індекс 0, але зберігся стабільний key?
Для інтроспекції внутрішніх структур Fiber у runtime-середовищі створимо легковажний інспектор, що дозволяє витягти пріоритети смуг та статус ноди безпосередньо з інстансу DOM:
interface FiberDebugSnapshot {
tag: number;
key: string | null;
typeName: string;
lanes: number;
lanesBinary: string;
hasPendingWork: boolean;
childCount: number;
}
const REACT_FIBER_PREFIX = '__reactFiber$';
export function getFiberFromDom(domElement: HTMLElement): FiberNode | null {
const fiberKey = Object.keys(domElement).find(key => key.startsWith(REACT_FIBER_PREFIX));
if (!fiberKey) {
return null;
}
return ((domElement as unknown) as Record<string, FiberNode>)[fiberKey] || null;
}
export function inspectFiberSubtree(fiber: FiberNode): FiberDebugSnapshot {
let childCount = 0;
let currentChild = fiber.child;
while (currentChild !== null) {
childCount++;
currentChild = currentChild.sibling;
}
const typeName =
typeof fiber.type === 'function'
? fiber.type.name || 'AnonymousFunction'
: typeof fiber.type === 'string'
? fiber.type
: 'Fragment/Special';
return {
tag: fiber.tag,
key: fiber.key,
typeName,
lanes: fiber.lanes,
lanesBinary: fiber.lanes.toString(2).padStart(31, '0'),
hasPendingWork: fiber.lanes !== 0 || fiber.childLanes !== 0,
childCount
};
}При сортуванні, фільтрації чи вставці елементів на початок масиву використання індексу key={index} змушує алгоритм Reconciliation зіставляти абсолютно різні сутності в першому проході. Fiber помилково перевикористовує старий FiberNode із чужим memoizedState, викликаючи баги розсинхронізації локального стану інпутів та зайві DOM-мутації.
// ❌ ПОГАНО: зміщення локального стану інпутів та скидання фокусу
{items.map((user, index) => (
<UserRow key={index} data={user} />
))}
// ✅ ПРАВИЛЬНО: стабільний унікальний ідентифікатор сутності з бази
{items.map((user) => (
<UserRow key={user.id} data={user} />
))}Якщо компонент оголошується всередині функції рендеру батька, на кожному циклі рендерингу створюється нове посилання на функцію elementType. В алгоритмі дифінгу перевірка oldFiber.elementType === newChild.type повертає false. У результаті React повністю знищує старий підграф Fiber (Unmount з усіма очищеннями useEffect) і монтує новий з нуля, втрачаючи весь стан.
// ❌ ПОГАНО: перестворення типу на кожному рендері, повне скидання DOM-дерева
export function OrderManager() {
const StatusBadge = ({ active }: { active: boolean }) => (
<span className={active ? 'green' : 'red'} />
);
return <StatusBadge active={true} />;
}
// ✅ ПРАВИЛЬНО: винесення компонента у стабільну область модуля
const StatusBadge = ({ active }: { active: boolean }) => (
<span className={active ? 'green' : 'red'} />
);
export function OrderManager() {
return <StatusBadge active={true} />;
}Метод flushSync перериває кооперативну роботу планувальника і негайно форсує виконання workLoopSync для всіх накопичених оновлень, очищаючи SyncLane. Зловживання flushSync всередині обробників подій або циклів блокує UI-потік і провокує випадання кадрів (Frame Drops).
import { flushSync } from 'react-dom';
// ❌ ПОГАНО: форсування синхронного повного рендеру на кожен крок
function handleBatchUpdates(items: string[]) {
items.forEach(item => {
flushSync(() => {
setList(prev => [...prev, item]);
});
});
}
// ✅ ПРАВИЛЬНО: пакетне автобатчування React 19 без втручання в потік
function handleBatchUpdates(items: string[]) {
setList(prev => [...prev, ...items]);
}Оскільки фаза Render є перериваною і може перезапускатися кілька разів до завершення кадру, будь-які прямі маніпуляції зі stateNode (DOM) чи зовнішніми об'єктами всередині тіла компонента призведуть до некоректного стану інтерфейсу та витоків пам'яті. Будь-які DOM-мутації допустимі лише у фазі Commit (useEffect, useLayoutEffect).