En la base de la arquitectura de React existe una separación fundamental de responsabilidades entre dos subsistemas independientes:
El paquete react-reconciler abre el acceso al núcleo de Fiber y permite a los ingenieros crear renderizadores reactivos declarativos personalizados para cualquier entorno de ejecución no estándar:
htop o k9s.No necesitas volver a implementar el programador de tareas, los algoritmos de recorrido de colas o la mecánica interna de hooks como useState y useEffect. Al implementar el contrato HostConfig de más de 20 métodos de ciclo de vida (createInstance, appendChild, prepareUpdate, commitUpdate), delegas en React toda la gestión de estado y planificación, centrándote exclusivamente en el dibujo físico de los nodos en la plataforma de destino.
La interacción entre el componente JSX del usuario, el núcleo del Reconciler y el entorno host se produce a través de una canalización estricta:
┌──────────────────────────────────────────────────────────┐
│ CÓDIGO JSX DEL USUARIO │
│ <Stage width={800} height={600}> │
│ <Rectangle x={10} y={20} fill="#3b82f6" /> │
│ <Circle radius={30} fill="#ef4444" /> │
│ </Stage> │
└────────────────────────────┬─────────────────────────────┘
│ Árbol de elementos React (Virtual)
▼
┌──────────────────────────────────────────────────────────┐
│ REACT 19 FIBER RECONCILER │
│ - Concurrent Mode / Transition Scheduler │
│ - Cálculo de diferencias entre current y work-in-prog │
│ - Formación de la lista de mutaciones (Effects) │
└────────────────────────────┬─────────────────────────────┘
│ Invocaciones a métodos de HostConfig
▼
┌──────────────────────────────────────────────────────────┐
│ HOST CONFIG PERSONALIZADO (Adaptador Renderer) │
│ createInstance('rectangle', props) │
│ prepareUpdate(instance, type, oldProps, newProps) │
│ commitUpdate(instance, updatePayload, type) │
└────────────────────────────┬─────────────────────────────┘
│ Comandos directos de la plataforma
▼
┌──────────────────────────────────────────────────────────┐
│ GRAFO DE ESCENA DE DESTINO (Host Environment) │
│ HTML5 Canvas 2D Context | CLI Terminal ANSI Canvas │
└──────────────────────────────────────────────────────────┘El contrato HostConfig divide estrictamente el ciclo de vida en dos fases:
createInstance, createTextInstance, appendInitialChild, prepareUpdate. Se ejecutan en memoria sobre nodos fuera de pantalla sin modificar la pantalla física. Si la renderización es interrumpida por el planificador (Concurrent interruption) debido a una tarea de mayor prioridad, las estructuras incompletas son desechadas por el recolector de basura (GC) sin dejar artefactos visuales.appendChild, removeChild, commitUpdate, resetAfterCommit. Aplicación sincrónica de los cambios calculados al grafo físico y renderizado final del fotograma.Definición de las clases base de nodos (CanvasNode, Container) con relaciones de árbol parent/children y propiedades geométricas.
Implementación de la fábrica createInstance, vinculación de árboles appendInitialChild y cálculo de deltas en prepareUpdate.
Inicialización de reconciler.createContainer con el flag ConcurrentRoot (React 19) y enlace con el lienzo nativo.
Recorrido del árbol de figuras en el hook resetAfterCommit y volcado del frame al 2D Context en menos de 16 ms.
Declaración de tipos globales de TypeScript para etiquetas personalizadas (<rect>, <circle>) con autocompletado de props.
¿Por qué está estrictamente prohibido invocar comandos de dibujo (como ctx.fillRect) dentro del método createInstance de HostConfig?
<Candlestick data={bar} />).Antes de configurar el Reconciler, se diseña una estructura de árbol en memoria para los nodos de la escena:
export type NodeType = 'stage' | 'rect' | 'circle' | 'text';
export interface BaseNodeProps {
x?: number;
y?: number;
[key: string]: unknown;
}
export interface RectProps extends BaseNodeProps {
width: number;
height: number;
fill: string;
}
export interface CircleProps extends BaseNodeProps {
radius: number;
fill: string;
}
export class CanvasNode {
public type: NodeType;
public props: Record<string, unknown>;
public children: CanvasNode[] = [];
public parent: CanvasNode | null = null;
constructor(type: NodeType, props: Record<string, unknown>) {
this.type = type;
this.props = { ...props };
}
public appendChild(child: CanvasNode): void {
child.parent = this;
this.children.push(child);
}
public removeChild(child: CanvasNode): void {
const index = this.children.indexOf(child);
if (index !== -1) {
child.parent = null;
this.children.splice(index, 1);
}
}
public insertBefore(child: CanvasNode, beforeChild: CanvasNode): void {
const index = this.children.indexOf(beforeChild);
if (index !== -1) {
child.parent = this;
this.children.splice(index, 0, child);
} else {
this.appendChild(child);
}
}
}El objeto de configuración se pasa a la factoría ReactReconciler(hostConfig):
import ReactReconciler from 'react-reconciler';
import { DefaultEventPriority } from 'react-reconciler/constants';
import { CanvasNode, type NodeType } from './CanvasNode';
export interface CanvasContainer {
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
rootNode: CanvasNode;
}
export type Instance = CanvasNode;
export type TextInstance = null;
export type SuspenseInstance = null;
export type HydratableInstance = null;
export type PublicInstance = CanvasNode;
export type HostContext = Record<string, unknown>;
export type UpdatePayload = Record<string, unknown> | null;
export const hostConfig: ReactReconciler.HostConfig<
NodeType,
Record<string, unknown>,
CanvasContainer,
Instance,
TextInstance,
SuspenseInstance,
HydratableInstance,
PublicInstance,
HostContext,
UpdatePayload,
unknown,
number,
unknown
> = {
supportsMutation: true,
supportsPersistence: false,
createInstance(type, props) {
return new CanvasNode(type, props);
},
createTextInstance() {
throw new Error('CanvasReconciler: Los nodos de texto deben envolverse en un componente <Text>');
},
appendInitialChild(parent, child) {
parent.appendChild(child);
},
appendChild(parent, child) {
parent.appendChild(child);
},
appendChildToContainer(container, child) {
container.rootNode.appendChild(child);
},
removeChild(parent, child) {
parent.removeChild(child);
},
removeChildFromContainer(container, child) {
container.rootNode.removeChild(child);
},
insertBefore(parent, child, beforeChild) {
parent.insertBefore(child, beforeChild);
},
insertInContainerBefore(container, child, beforeChild) {
container.rootNode.insertBefore(child, beforeChild);
},
// Cálculo del diff de props en la fase Render
prepareUpdate(instance, type, oldProps, newProps) {
const payload: Record<string, unknown> = {};
let hasChanges = false;
for (const key of Object.keys(newProps)) {
if (newProps[key] !== oldProps[key] && key !== 'children') {
payload[key] = newProps[key];
hasChanges = true;
}
}
return hasChanges ? payload : null;
},
// Aplicación atómica del diff en la fase Commit
commitUpdate(instance, updatePayload) {
if (updatePayload) {
Object.assign(instance.props, updatePayload);
}
},
clearContainer(container) {
container.rootNode.children = [];
},
getRootHostContext() {
return {};
},
getChildHostContext(parentHostContext) {
return parentHostContext;
},
shouldSetTextContent() {
return false;
},
finalizeInitialChildren() {
return false;
},
prepareForCommit() {
return null;
},
resetAfterCommit(container) {
// Punto de sincronización: repintado del frame en Canvas tras aplicar todas las mutaciones
redrawCanvas(container);
},
getCurrentEventPriority() {
return DefaultEventPriority;
},
getInstanceFromNode() {
return null;
},
beforeActiveInstanceBlur() {},
afterActiveInstanceBlur() {},
prepareScopeUpdate() {},
getInstanceFromScope() {
return null;
},
detachDeletedInstance() {},
};
function redrawCanvas(container: CanvasContainer): void {
const { ctx, canvas, rootNode } = container;
ctx.clearRect(0, 0, canvas.width, canvas.height);
function renderSubtree(node: CanvasNode): void {
if (node.type === 'rect') {
ctx.fillStyle = (node.props.fill as string) || '#000';
ctx.fillRect(
(node.props.x as number) || 0,
(node.props.y as number) || 0,
(node.props.width as number) || 50,
(node.props.height as number) || 50
);
} else if (node.type === 'circle') {
ctx.fillStyle = (node.props.fill as string) || '#000';
ctx.beginPath();
ctx.arc(
(node.props.x as number) || 0,
(node.props.y as number) || 0,
(node.props.radius as number) || 20,
0,
Math.PI * 2
);
ctx.fill();
}
for (const child of node.children) {
renderSubtree(child);
}
}
for (const child of rootNode.children) {
renderSubtree(child);
}
}Creación de FiberRootNode mediante el Reconciler vinculado al 2D Context nativo de HTML5 Canvas.
Fiber construye el árbol offline CanvasNode en memoria y calcula las diferencias de props (x, y, fill).
El Reconciler aplica el diff calculado al grafo de la escena sin bloquear el DOM del navegador.
El hook de finalización del commit limpia el lienzo y realiza un recorrido recursivo sobre el grafo de figuras.
¿Qué debe retornar el método prepareUpdate si en un componente <Rectangle width={100} fill='blue' /> solo cambia el color de relleno a fill='red'?
A continuación se muestra el punto de entrada para producción del renderizador personalizado de Canvas CanvasRenderer, implementando la API de inicialización de contenedores createRoot basada en el Concurrent Root de React 19:
import React from 'react';
import ReactReconciler from 'react-reconciler';
import { hostConfig, type CanvasContainer } from './hostConfig';
import { CanvasNode } from './CanvasNode';
const reconciler = ReactReconciler(hostConfig);
/**
* Renderizador para dibujar componentes React directamente sobre HTML5 Canvas
*/
export class CanvasRenderer {
private container: CanvasContainer;
private fiberRoot: ReactReconciler.FiberRoot;
private isMounted: boolean = true;
constructor(canvas: HTMLCanvasElement) {
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('[CanvasRenderer]: No se pudo obtener CanvasRenderingContext2D');
}
this.container = {
canvas,
ctx,
rootNode: new CanvasNode('stage', {}),
};
// 0 = LegacyRoot, 1 = ConcurrentRoot (estándar de React 19)
const ConcurrentRootTag = 1;
this.fiberRoot = reconciler.createContainer(
this.container,
ConcurrentRootTag,
null,
false,
null,
'',
(error: Error) => {
console.error('[Canvas Reconciler Fatal Error]:', error);
},
null
);
}
/**
* Monta o actualiza un árbol JSX en el contenedor de Canvas
*/
public render(element: React.ReactNode): void {
if (!this.isMounted) {
throw new Error('[CanvasRenderer]: No se puede invocar render en un contenedor desmontado');
}
reconciler.updateContainer(element, this.fiberRoot, null, () => {
// Callback opcional ejecutado al completar el montaje inicial del árbol
});
}
/**
* Desmontaje seguro del árbol con ejecución de todas las limpiezas de useEffect/useLayoutEffect
*/
public unmount(): void {
if (!this.isMounted) return;
reconciler.updateContainer(null, this.fiberRoot, null, () => {
hostConfig.clearContainer(this.container);
this.container.ctx.clearRect(
0,
0,
this.container.canvas.width,
this.container.canvas.height
);
this.isMounted = false;
});
}
}Invocar comandos de dibujo (ctx.fillRect(...)) dentro de createInstance o prepareUpdate. En Concurrent Mode, la fase Render puede pausarse, dividirse o reiniciarse. Esto generará figuras fantasma dibujadas en pantalla que no existen en el árbol final de componentes.
// ❌ Antipatrón: efecto secundario en la fase Render!
createInstance(type, props) {
ctx.fillRect(props.x, props.y, props.w, props.h); // ¡Dibuja antes de confirmarse el commit!
return new CanvasNode(type, props);
}
// ✅ Solución correcta: crear únicamente estructuras de datos en memoria.
// El dibujo se realiza estrictamente en el hook resetAfterCommit tras el commit.Si prepareUpdate retorna true o un objeto vacío en lugar de un diccionario explícito de propiedades modificadas, commitUpdate no podrá discernir qué parámetros han cambiado, perdiendo todas las ventajas del diffing granular.
// ❌ Antipatrón: pérdida de granularidad en la mutación
prepareUpdate() {
return true; // commitUpdate no sabe QUÉ cambió exactamente
}
// ✅ Solución correcta: retornar un diccionario claro de mutaciones
prepareUpdate(instance, type, oldProps, newProps) {
const diff: Record<string, unknown> = {};
if (oldProps.fill !== newProps.fill) diff.fill = newProps.fill;
return Object.keys(diff).length > 0 ? diff : null;
}Los elementos dentro de Canvas (como <Circle /> o <Rectangle />) son nodos de la escena virtual CanvasNode, no elementos DOM del navegador. No soportan addEventListener nativo.
Solución: Registrar un único listener de clics en el elemento HTML <canvas> externo, leer las coordenadas event.offsetX, event.offsetY y realizar un algoritmo de Hit Testing matemático a través del grafo en orden inverso de Z-index.
Al eliminar un nodo en removeChild, es imperativo anular explícitamente la referencia: child.parent = null. De lo contrario, las referencias circulares entre nodos del grafo y los cierres de controladores de eventos quedarán retenidos en el JS Heap incluso tras desmontar el componente.
react-reconciler permite implementar la interfaz HostConfig para construir renderizadores para Canvas, WebGL, Terminal (CLI) y PDF.createInstance, appendInitialChild y prepareUpdate operan en la fase Render y deben permanecer estrictamente libres de efectos secundarios o comandos de dibujo.resetAfterCommit.