El modelo declarativo: qué aporta React Three Fiber y qué esconde
Cómo un reconciliador convierte JSX en un grafo de escena, qué problemas resuelve de verdad, qué coste añade, y cuándo conviene bajar al modo imperativo.
React Three Fiber no es una librería de componentes que envuelve a Three.js: es un reconciliador de React alternativo cuyo destino, en lugar del DOM, es el grafo de escena. Esa distinción explica todo lo que hace bien y todo lo que cuesta. Bien: la escena se declara, se compone y se limpia con las mismas reglas que la interfaz, y el ciclo de vida de un objeto 3D deja de ser tu responsabilidad. Cuesta: entre tu intención y la llamada a renderer.render hay una capa que decide cosas por ti, y cuando el rendimiento se estropea hace falta saber exactamente qué decide.
- Explicar qué hace un reconciliador con destino no-DOM y cómo se traduce JSX a objetos de Three.
- Enumerar los tres problemas reales que resuelve y que justifican adoptarlo.
- Identificar qué operaciones son caras dentro del modelo declarativo y cómo esquivarlas.
- Reconocer los equivalentes en otros frameworks y qué tienen en común.
Qué hace realmente un reconciliador con destino de escena
React separa dos cosas que la mayoría de la gente ve como una: el algoritmo que compara árboles de elementos y decide qué cambió, y el código que aplica esos cambios a un destino concreto. El primero es React. El segundo es el renderizador, y react-dom es solo uno de los posibles.
React Three Fiber implementa otro renderizador. Cuando escribes <mesh>, no hay ningún componente llamado mesh: el reconciliador ve una etiqueta en minúsculas, la busca en el catálogo de clases de Three.js, y crea un THREE.Mesh. Cuando escribes <boxGeometry args={[1, 2, 1]} />, crea new THREE.BoxGeometry(1, 2, 1) y lo asigna a la propiedad geometry del padre, porque sabe que un objeto de tipo geometría colgado de una malla va ahí.
import { Canvas, useFrame } from '@react-three/fiber';
import { useRef, useState } from 'react';
function Nudo({ posicion }) {
const ref = useRef();
const [activo, setActivo] = useState(false);
useFrame((estado, dt) => {
ref.current.rotation.y += dt * (activo ? 3 : 0.5);
});
return (
<mesh
ref={ref}
position={posicion}
onClick={() => setActivo((v) => !v)}
>
<torusKnotGeometry args={[0.7, 0.25, 128, 32]} />
<meshStandardMaterial color={activo ? '#f38ba8' : '#89b4fa'} roughness={0.3} />
</mesh>
);
}
export default function Visor() {
return (
<Canvas camera={{ position: [0, 0, 4], fov: 50 }}>
<hemisphereLight args={[0xffffff, 0x223344, 2]} />
<Nudo posicion={[0, 0, 0]} />
</Canvas>
);
}
Tres traducciones se han producido aquí sin que se vean. args es el array de argumentos del constructor, y cambiar args reconstruye el objeto entero, porque un constructor no se puede volver a llamar sobre una instancia. position={[0, 0, 0]} aprovecha que la propiedad destino tiene un método set, así que el reconciliador llama a position.set(0, 0, 0) en lugar de reemplazar el vector. Y color="#89b4fa" construye un THREE.Color y lo asigna.
La distinción entre args y el resto de propiedades es la más importante de todo el modelo, y la fuente de más problemas de rendimiento. Un args que depende del estado reconstruye geometría y sube buffers a la GPU en cada cambio.
Los tres problemas que resuelve
El ciclo de vida de los recursos. Cuando un componente se desmonta, el reconciliador retira sus objetos del grafo y llama a dispose sobre lo que creó. La categoría entera de fugas de la lección dispose de verdad desaparece para los recursos que declaraste en JSX. Es la ventaja más grande y la que más justifica adoptarlo en una aplicación con muchas escenas.
Con una salvedad grande: solo alcanza lo que el reconciliador creó. Un modelo cargado con un loader dentro de un useEffect sigue siendo tuyo, y un render target que creas a mano también.
La composición. Un grupo de objetos con su lógica se convierte en un componente reutilizable con props, y componerlos es escribir JSX. En modo imperativo eso mismo exige inventarse una convención de factorías, y cada proyecto se inventa una distinta.
La sincronización con la interfaz. El estado que controla un panel de HTML y el estado que controla la escena son el mismo estado, gestionado con las mismas herramientas. No hay frontera que cruzar ni objeto mutable intermedio.
useFrame recibe el estado global de la escena y el delta ya calculado y acotado. Es el equivalente del bucle, y todo lo dicho sobre no llamar a setState dentro sigue aplicando palabra por palabra: un setState en useFrame reconcilia el árbol sesenta veces por segundo. La diferencia es que aquí la tentación es mayor, porque el estado está a mano.
Qué esconde y cuánto cuesta
El coste no está donde la gente cree. Reconciliar un árbol de trescientos objetos 3D no es caro si el árbol no cambia: React solo recorre lo que se ha marcado como sucio. El coste aparece en cuatro sitios concretos.
El primero es la reconstrucción por args. Cualquier prop que llegue a args y venga de estado provoca un new de la clase correspondiente y, si es geometría o textura, una subida a la GPU. Un deslizador que controla el radio de una esfera a través de args sube buffers en cada movimiento del ratón. La solución es no pasar por args: crea la geometría una vez y modifica lo que se pueda modificar, o usa scale en lugar de reconstruir.
El segundo son las props creadas en línea. position={[0, 0, 0]} crea un array nuevo en cada render del componente. El reconciliador lo compara por identidad, ve que cambió, y llama a position.set otra vez. Es barato, pero multiplicado por mil objetos y por render sí se nota. Con arrays literales de números el efecto es menor que con objetos; con funciones en línea en onClick el efecto es que el manejador se reasigna siempre.
El tercero es la cantidad de objetos. Mil componentes que devuelven un <mesh> son mil fibras de React, mil objetos de Three y mil draw calls. El modelo declarativo hace tan cómodo escribir eso que se escribe sin pensarlo, mientras que en modo imperativo el bucle de mil iteraciones te hace preguntarte si no debería ser un InstancedMesh. Es un coste cultural más que técnico, pero es real.
El cuarto es el descubrimiento. Cuando algo va mal, la traza pasa por el reconciliador y no por tu código, y el objeto de Three que te interesa está detrás de un ref. Depurar exige conocer las dos capas, no una.
// Caro: reconstruye la geometria en cada movimiento del deslizador
<mesh>
<sphereGeometry args={[radio, 32, 16]} />
</mesh>
// Barato: una geometria fija y una escala que es una propiedad
<mesh scale={radio}>
<sphereGeometry args={[1, 32, 16]} />
</mesh>
Cuando el modelo declarativo se interpone de verdad, la salida es limpia: useRef sobre el objeto y trabajar imperativamente dentro de useFrame. No es una derrota ni una mala práctica, es el diseño previsto. Un sistema de partículas, un BatchedMesh con diez mil instancias o una simulación en GPU se escriben imperativamente y se declaran como un único nodo del árbol.
El error de rendimiento más caro con el modelo declarativo tiene un nombre: se llama poner la escena entera dentro del mismo componente que el estado que cambia rápido. Si el estado del ratón vive en el componente que contiene el <Canvas>, cada movimiento del puntero reconcilia el árbol 3D completo, aunque ni un solo objeto dependa de ese estado. React no sabe que la escena es cara. La solución es la de siempre en React, y aquí es crítica: baja el estado al componente más pequeño que lo necesite, o sácalo a un almacén externo que solo suscriba a quien lo lee. La diferencia entre un <Canvas> que reconcilia con cada evento y uno que no reconcilia nunca son treinta fotogramas por segundo, y no aparece en ningún perfilado de WebGL porque el tiempo se lo lleva JavaScript.
Los equivalentes y lo que tienen en común
La idea no es exclusiva de React. Cada framework con un sistema de componentes ha producido su versión.
| Framework | Proyecto | Mecanismo |
|---|---|---|
| React | React Three Fiber | Reconciliador propio de React |
| Vue | TresJS | Renderizador personalizado de Vue |
| Svelte | Threlte | Componentes con contexto |
| Solid | solid-three | Renderizador universal de Solid |
Los cuatro comparten el mismo contrato conceptual: etiquetas que corresponden a clases de Three.js, props que corresponden a propiedades, un hook o equivalente para engancharse al bucle, y limpieza automática de lo declarado. Aprendido uno, los demás se leen sin esfuerzo.
Y los cuatro comparten las mismas limitaciones. Ninguno te libera de entender el pipeline gráfico. Ninguno hace que una escena mal construida vaya rápido. Ninguno gestiona los recursos que creas fuera de su árbol. Y en todos, el punto donde la abstracción deja de ayudar es el mismo: cuando necesitas control fino sobre el orden de las operaciones dentro del fotograma.
La decisión de adoptarlos o no se reduce a una pregunta honesta sobre el proyecto. Si la escena es una parte de una aplicación con mucha interfaz, mucho estado compartido y muchas rutas que montan y desmontan, el modelo declarativo devuelve con creces lo que cuesta, sobre todo por la gestión automática de recursos. Si la escena es la aplicación, con su propio bucle, su propia gestión de assets y poca interfaz alrededor, la capa añade indirección sin resolver nada que no tuvieras ya resuelto.
Lo que no es defendible es adoptarla sin entender la capa de debajo. El modelo declarativo hace más fácil escribir una escena y no hace más fácil arreglarla: cuando el frame se va a cincuenta milisegundos, la respuesta sigue estando en draw calls, en overdraw y en programas de shader, exactamente igual que en modo imperativo. La abstracción cubre la construcción, no el rendimiento.
Escribe la misma escena dos veces: una con el modelo declarativo y otra con la factoría imperativa de la primera lección del nivel. Añade a las dos un deslizador que cambie un parámetro de la geometría. Mide con el panel de rendimiento cuántos milisegundos de JavaScript consume cada versión mientras arrastras el deslizador. Luego arregla la versión declarativa sacando el parámetro de args y vuelve a medir: la diferencia entre las dos versiones declarativas suele ser mayor que la diferencia entre declarativo e imperativo.