Antes de la versión 16, el núcleo de React utilizaba un motor de comparación de árboles virtuales recursivo conocido como el Stack Reconciler. Ante cualquier actualización de estado, el motor recorría todo el árbol de componentes de arriba a abajo de manera estrictamente síncrona en la pila de llamadas nativa de JavaScript (Call Stack).
En árboles que contenían miles de nodos, esta operación bloqueaba el hilo principal del navegador durante 50–200 ms. A lo largo de esta ventana de bloqueo, el navegador era totalmente incapaz de responder a las interacciones del usuario, actualizar animaciones CSS o renderizar fotogramas, lo que provocaba saltos visuales y pérdida de fotogramas (Frame Drops).
La arquitectura React Fiber reconstruyó este paradigma desde sus fundamentos. En lugar de depender de la pila de llamadas implícita del motor, Fiber implementa una estructura de pila virtual alojada en el montón (heap-allocated virtual stack frame), donde cada nodo del árbol está representado por una estructura de datos FiberNode. Este cambio arquitectónico permitió:
A diferencia del árbol DOM tradicional o de los árboles vDOM que se basan en matrices de hijos (children: [...]), el grafo interno de Fiber está organizado como una estructura de lista simplemente enlazada: cada nodo apunta exclusivamente a su primer hijo (child), a su siguiente hermano (sibling) y a su nodo padre (return).
[ Árbol actual (Montado en el DOM) ] [ Árbol WorkInProgress (Construido en segundo plano) ]
FiberRoot FiberRoot
│ │
(puntero current) (workInProgress)
▼ ▼
[FiberNode: App] <====== alternate ======> [FiberNode: App]
/ ▲ / ▲
child/ \return child/ \return
▼ \ ▼ \
[FiberNode: Header] \ [FiberNode: Header] \
│ \ │ \
sibling\ \ sibling\ \
▼ \ ▼ \
[FiberNode: Main] ─────┘ [FiberNode: Main] ─────┘Esta estructura enlazada proporciona una ventaja de ingeniería fundamental: el grafo se recorre de manera iterativa dentro de un bucle plano while sin recurrir a la recursión en el Call Stack. En cualquier instante, el runtime puede conservar una referencia al nodo activo, devolver el control del hilo al navegador para ejecutar el diseño y la pintura, y reanudar el cómputo exactamente desde el punto interrumpido.
El núcleo de React conserva dos árboles sincronizados en la memoria heap:
workInProgress finalizado en una única asignación atómica O(1): FiberRoot.current = workInProgress.El doble búfer garantiza que el usuario final nunca presencie estados intermedios o desincronizados de la interfaz. Si el renderizado de un subárbol pesado se interrumpe por un error o es desalojado por pulsaciones de teclas prioritarias, el árbol incompleto workInProgress simplemente se desecha sin comprometer la pantalla activa.
Una acción del usuario desencadena una actualización. El planificador genera una máscara de bits de prioridad (como SyncLane o TransitionLane) y marca la ruta hacia la raíz FiberRoot a través de childLanes.
El nodo raíz workInProgress se recupera o instancia mediante su puntero alternate, duplicando las props del árbol current activo.
Recorrido descendente. Ejecución de funciones de componente, cálculo del nuevo estado, evaluación de salidas rápidas por memoización (bailout) y reconciliación de hijos JSX en nuevos FiberNodes.
Creación y configuración de nodos DOM en memoria, vinculación de propiedades de host, construcción de colas de efectos y propagación ascendente de la máscara subtreeFlags.
Aplicación síncrona de mutaciones al DOM: ejecución de destructores useLayoutEffect, mutación de elementos host, intercambio atómico de FiberRoot.current y programación de useEffect pasivos.
¿Por qué el núcleo de React Fiber descartó los árboles clásicos basados en matrices de hijos (children: [...]) a favor de punteros de lista simplemente enlazada (child, sibling, return)?
El renderizado en React se divide estrictamente en dos fases conceptuales: la Render Phase (asíncrona, interrumpible y libre de efectos secundarios) y la Commit Phase (síncrona, ininterrumpible y responsable de mutar los nodos del DOM).
Genera la máscara de bits del evento (SyncLane para entrada o TransitionLane para búsquedas) y propaga childLanes desde el nodo destino hacia FiberRoot.
Procesa pasos discretos de performUnitOfWork. Desciende mediante beginWork y asciende mediante completeWork controlando los presupuestos de shouldYield().
Compara el FiberNode actual contra los nuevos elementos JSX. Aplica banderas de efectos de mutación (Placement, Update, ChildDeletion).
Aplica efectos de mutación síncronamente: invoca destructores de useLayoutEffect, actualiza el DOM de host, intercambia current y programa useEffect.
Cada FiberNode es asignado por el runtime central. A continuación se presenta la interfaz estricta de TypeScript con los campos internos fundamentales en 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; // Máscara de bits de mutación (Placement, Update, Deletion)
export type Lane = number; // Máscara de bits de prioridad de 31 bits
export type Lanes = number;
export interface FiberNode {
// Identidad y descriptores de elementos
tag: WorkTag;
key: null | string;
elementType: unknown;
type: unknown;
stateNode: unknown; // Puntero al nodo DOM del host o instancia de componente
// Punteros de recorrido del grafo
return: FiberNode | null; // Nodo padre (destino del ascenso)
child: FiberNode | null; // Primer nodo hijo
sibling: FiberNode | null; // Siguiente nodo hermano
index: number;
// Entrada y estado computado
pendingProps: Record<string, unknown>; // Props en espera de procesamiento
memoizedProps: Record<string, unknown>; // Props renderizadas en el commit anterior
memoizedState: unknown; // Lista enlazada de hooks (Hook linked list)
updateQueue: unknown; // Cola de actualizaciones de estado
// Banderas y carriles de prioridad
flags: Flags; // Efectos secundarios locales de mutación en este nodo
subtreeFlags: Flags; // Efectos secundarios acumulados en el subárbol descendiente
deletions: Array<FiberNode> | null; // Nodos hijos programados para eliminación
lanes: Lanes; // Carriles de prioridad pendientes en este nodo
childLanes: Lanes; // Carriles de prioridad pendientes en el subárbol
// Mecanismo de doble búfer
alternate: FiberNode | null; // Puntero dual entre current y workInProgress
}La ejecución de la fase de renderizado está coordinada por la función workLoopConcurrent. El método shouldYield() consulta al Scheduler, que asigna presupuestos de ejecución de 5 ms:
import { shouldYield } from './schedulerHostConfig';
let workInProgress: FiberNode | null = null;
let renderLanes: Lanes = 0;
export function workLoopConcurrent(): void {
// Ejecuta unidades de trabajo cooperativamente hasta finalizar o agotar el tiempo
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
function performUnitOfWork(unitOfWork: FiberNode): void {
const current = unitOfWork.alternate;
// 1. Fase de descenso: evaluar función del componente, reconciliar hijos JSX
let next: FiberNode | null = beginWork(current, unitOfWork, renderLanes);
unitOfWork.memoizedProps = unitOfWork.pendingProps;
if (next === null) {
// 2. Si no hay hijos, iniciamos la fase de ascenso
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;
// Ensamblar propiedades del host y propagar subtreeFlags hacia arriba
completeWork(current, completedWork, renderLanes);
const siblingFiber: FiberNode | null = completedWork.sibling;
if (siblingFiber !== null) {
// Si existe un hermano, cambiamos el foco para descender por él
workInProgress = siblingFiber;
return;
}
// Ascender al nodo padre
completedWork = returnFiber;
workInProgress = completedWork;
} while (completedWork !== null);
}A diferencia del paradigma heredado de expirationTime, que modelaba la prioridad como números enteros escalares (introduciendo bloqueos al inicio de la cola entre actualizaciones no relacionadas), Lanes representa la prioridad como canales binarios independientes. Un entero de 32 bits proporciona 31 carriles de prioridad independientes:
export const TotalLanes = 31;
export const NoLanes: Lanes = 0b0000000000000000000000000000000;
export const NoLane: Lane = 0b0000000000000000000000000000000;
// Actualizaciones síncronas bloqueantes (entrada discreta: clics, tipeo)
export const SyncLane: Lane = 0b0000000000000000000000000000001;
// Entrada de usuario continua (desplazamiento, movimiento del ratón)
export const InputContinuousLane: Lane = 0b0000000000000000000000000000010;
// Actualizaciones asíncronas estándar (estados normales, respuestas de red)
export const DefaultLane: Lane = 0b0000000000000000000000000010000;
// Transiciones (startTransition, useDeferredValue)
export const TransitionLanes: Lanes = 0b0000000011111111111111100000000;
export const TransitionLane1: Lane = 0b0000000000000000000000100000000;
// Tareas en segundo plano en reposo (idle)
export const IdleLanes: Lanes = 0b0110000000000000000000000000000;
/**
* Extrae el carril de mayor prioridad (bit activo situado más a la derecha)
* Modismo a nivel de bits: x & -x retorna el bit activo menos significativo
*/
export function getHighestPriorityLane(lanes: Lanes): Lane {
return lanes & -lanes;
}
/**
* Comprueba si un conjunto de carriles contiene un subconjunto
*/
export function isSubsetOfLanes(set: Lanes, subset: Lanes | Lane): boolean {
return (set & subset) === subset;
}
/**
* Combina máscaras de bits de carriles
*/
export function mergeLanes(a: Lanes | Lane, b: Lanes | Lane): Lanes {
return a | b;
}
/**
* Elimina carriles procesados del conjunto activo
*/
export function removeLanes(set: Lanes, subset: Lanes | Lane): Lanes {
return set & ~subset;
}La expresión lanes & -lanes hace uso de la representación en Complemento a Dos de los enteros con signo. A nivel de hardware, -lanes equivale a ~lanes + 1. La operación lógica AND borra todos los bits excepto el bit menos significativo en un único ciclo de CPU, permitiendo al planificador resolver instantáneamente el carril de mayor prioridad.
React resuelve el reto de comparar dos listas de hijos en tiempo lineal O(N) en lugar de la distancia de edición estándar de grafos O(N^3) mediante una heurística en dos pasadas:
key coincidan. Al encontrar la primera discrepancia de clave, el bucle se detiene.Map<Key | Index, FiberNode>. Los nuevos elementos consultan este mapa en tiempo O(1). Los nodos coincidentes se reutilizan con la bandera Placement, y los antiguos no encontrados se marcan con 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;
// PASADA 1: Iterar secuencialmente mientras las claves coincidan
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;
}
// Si se han agotado los elementos nuevos, eliminar los antiguos sobrantes
if (newIdx === newChildren.length) {
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
// PASADA 2: Ubicar los nodos antiguos restantes en un Map para coincidencia 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;
}
}
// Los nodos que permanezcan en el mapa se programan para eliminación
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) {
// El elemento se movió a la derecha con respecto a su orden original
newFiber.flags |= 0b0000000000000000000000000000010; // Placement
return lastPlacedIndex;
}
return oldIndex;
}
// Nodo recién insertado
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;
}
}¿Qué sucede durante la segunda pasada de reconcileChildrenArray cuando un elemento del array se desplaza del índice 5 al índice 0 manteniendo una key estable?
Para inspeccionar los detalles internos de Fiber en tiempo de ejecución, esta utilidad extrae los carriles de prioridad activos y las estadísticas de los nodos directamente desde elementos del DOM en vivo:
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
};
}Al ordenar, filtrar o anteponer elementos, utilizar key={index} obliga al algoritmo de reconciliación a asociar entidades no relacionadas durante la primera pasada. Fiber reutiliza instancias de FiberNode anteriores con un memoizedState desalineado, originando anomalías de sincronización en entradas de formularios y mutaciones DOM innecesarias.
// ❌ INCORRECTO: Desalinea el estado interno de los hooks y pierde el foco de entrada
{items.map((user, index) => (
<UserRow key={index} data={user} />
))}
// ✅ CORRECTO: Utilizar identificadores únicos y estables procedentes de la base de datos
{items.map((user) => (
<UserRow key={user.id} data={user} />
))}Declarar un componente dentro de la función de renderizado de su padre crea una nueva referencia funcional para elementType en cada pasada de render. Durante la reconciliación, oldFiber.elementType === newChild.type se evalúa como false. React destruye por completo el subárbol Fiber existente (desmontando y ejecutando las limpiezas de useEffect) y monta uno nuevo desde cero, perdiendo todo el estado acumulado.
// ❌ INCORRECTO: Crea un nuevo tipo en cada renderizado, reiniciando el DOM y el estado
export function OrderManager() {
const StatusBadge = ({ active }: { active: boolean }) => (
<span className={active ? 'green' : 'red'} />
);
return <StatusBadge active={true} />;
}
// ✅ CORRECTO: Elevar los componentes al ámbito del módulo
const StatusBadge = ({ active }: { active: boolean }) => (
<span className={active ? 'green' : 'red'} />
);
export function OrderManager() {
return <StatusBadge active={true} />;
}Invocar flushSync elude el planificador cooperativo y fuerza la ejecución inmediata y síncrona de workLoopSync para las actualizaciones pendientes, vaciando el carril SyncLane. El uso excesivo de flushSync en bucles o controladores de eventos bloquea el hilo principal de la UI e introduce caídas de fotogramas.
import { flushSync } from 'react-dom';
// ❌ INCORRECTO: Fuerza un pase de render síncrono completo en cada iteración
function handleBatchUpdates(items: string[]) {
items.forEach(item => {
flushSync(() => {
setList(prev => [...prev, item]);
});
});
}
// ✅ CORRECTO: Confiar en el procesamiento por lotes automático de React 19
function handleBatchUpdates(items: string[]) {
setList(prev => [...prev, ...items]);
}Debido a que la fase de Render es interrumpible y puede reiniciarse múltiples veces antes del commit, mutar directamente stateNode (DOM) o alterar variables externas dentro del cuerpo del componente genera inconsistencias visuales y fugas de memoria. Las mutaciones del DOM deben confinarse de forma estricta a la fase de Commit (useEffect, useLayoutEffect).