wandres.dev
CREATEROOT Y DISPOSAL · raíces reactivas

createRoot: fabricar un scope reactivo

createRoot abre a mano un owner reactivo fuera de todo componente: recibe un callback con dispose, devuelve lo que ese callback retorne, posee pero no rastrea y no se auto-dispone. Es la primitiva que silencia el aviso de computaciones huérfanas y la raíz sobre la que se construye toda reactividad de larga vida.

⏱ 16 min

Hasta aquí los owners te venían regalados: un componente crea el suyo, render crea el de la aplicación, un efecto padre apadrina a sus hijos. Nunca fabricaste uno tú. createRoot rompe esa comodidad: te deja abrir un ámbito reactivo a mano, en cualquier punto del programa —fuera de todo componente, dentro de un setTimeout, en un test, en una herramienta— y te entrega la llave para cerrarlo. Es la primitiva más honesta del sistema de ownership, porque expone lo que las demás disimulan: que toda reactividad que sobreviva a un instante necesita una raíz, y que alguien debe decidir cuándo esa raíz muere.

🎯 Al terminar esta lección sabrás
  • Crear un ámbito reactivo manual con createRoot fuera de cualquier componente.
  • Entender la firma createRoot(dispose => valor) y qué recibe y devuelve.
  • Distinguir dos roles que se confunden: poseer computaciones y rastrear señales.
  • Reconocer el aviso de computaciones huérfanas y por qué createRoot lo silencia.

La firma: un callback que recibe dispose

createRoot toma una función y la ejecuta de inmediato, pasándole un único argumento: dispose, la función que destruye todo lo creado dentro del ámbito. Lo que tu callback devuelva es lo que createRoot devuelve, así que la usas como un embudo para sacar del ámbito exactamente lo que quieras conservar.

import { createRoot, createSignal, createEffect } from "solid-js";

const api = createRoot((dispose) => {
  const [count, setCount] = createSignal(0);

  createEffect(() => console.log("count:", count()));

  // El valor de retorno es lo que createRoot entrega hacia fuera.
  return { count, setCount, dispose };
});

api.setCount(1); // el efecto sigue vivo y reacciona: "count: 1"
api.dispose();   // se cierra el ambito: el efecto muere
api.setCount(2); // silencio; el signal existe, pero ya nadie lo observa

El efecto vive dentro de la raíz. Mientras no la dispongas, reacciona a count con total normalidad, igual que si estuviera en un componente. En cuanto invocas dispose, Solid destruye ese efecto —y cualquier memo, cualquier onCleanup y cualquier raíz hija que hubieras anidado— y el signal queda inerte: persiste como valor en memoria, pero sin observadores vivos su cambio no propaga a nadie.

createRoot ejecuta el callback de forma ávida: no aplaza nada, no espera a un montaje. En el momento en que la llamada retorna, el efecto interno ya corrió su primera vez y el objeto que devolviste está listo para usarse. Por eso cristalizan dos convenciones de retorno que cubren casi todo: devolver una API —getters y acciones, con el dispose incluido para cerrar cuando toque— o devolver solo el dispose cuando el ámbito trabaja por sus efectos secundarios y no expone ninguna lectura. La elección la dicta una pregunta: ¿alguien de fuera necesita leer este estado, o solo necesita poder apagarlo?

Poseer no es rastrear

Una raíz es un owner que posee computaciones pero no rastrea señales, y que no se dispone solo. Conviene separar con bisturí dos papeles que la palabra «reactivo» amontona.

  • Poseer es acumular hijos —efectos, memos, limpiezas, subraíces— en una lista para destruirlos en bloque. Eso sí lo hace la raíz: es un nodo del árbol de ownership.
  • Rastrear es suscribirse a las señales que se leen. Eso no lo hace la raíz: el cuerpo del callback corre sin listener activo, de modo que leer count() en el nivel superior de la raíz no crea ninguna dependencia.
createRoot((dispose) => {
  const [n] = createSignal(0);
  console.log(n());        // lectura NO rastreada: no suscribe a nada
  createEffect(() => n()); // aqui SI: el efecto es quien rastrea
  dispose();
});

Por eso el nombre root: es la cima de un subárbol, un contenedor de vidas, no un reactor. Rastrear es tarea de las hojas que cuelgan de ella —los efectos y memos—, no del tronco.

Si abrieras la caja, un owner resulta ser una estructura modesta: una lista de las computaciones que posee, una lista de limpiezas pendientes, un puntero a su owner padre y el contexto que hereda. Ni una señal, ni una suscripción; esas viven en el grafo de dependencias, que es la otra estructura. Una raíz es, sencillamente, el nodo de esa jerarquía de posesión al que nadie apunta como padre disponible.

// Forma aproximada de un Owner en Solid, simplificada.
type Owner = {
  owned: Computation[] | null;             // hijos que moriran con el
  cleanups: (() => void)[] | null;         // los onCleanup registrados
  owner: Owner | null;                     // padre en el arbol de ownership
  context: Record<symbol, unknown> | null; // contexto heredado
};

Ver esa forma desmitifica todo lo demás: dispose recorre owned, ejecuta cleanups, y desengancha las computaciones del grafo. No hay más magia que una lista y un recorrido.

flowchart TD
G[Sin owner: Owner es null] -->|createEffect aqui| W[Aviso: nunca se dispondra]
R[createRoot crea un owner raiz] --> O[Owner con lista owned y cleanups]
O --> E[createEffect vive dentro]
O --> M[createMemo vive dentro]
D[dispose recorre el subarbol] --> K[Ejecuta cleanups de abajo hacia arriba]
style R fill:#a6e3a1,color:#11111b
style W fill:#f38ba8,color:#11111b
style D fill:#89b4fa,color:#11111b

El aviso que silencia

Si creas un createEffect o un createMemo sin ningún owner activo —en el nivel de un módulo, dentro de un setTimeout, en una callback suelta— Solid te avisa por consola en desarrollo: computations created outside a createRoot or render will never be disposed. No es una advertencia decorativa: sin owner, esa computación no tiene quién ejecute su limpieza ni quién la destruya, así que vivirá y seguirá suscrita hasta que el programa termine. Es una fuga por construcción.

createRoot es la respuesta canónica: instala un owner, la computación cae dentro de él, el aviso desaparece y —lo esencial— ahora existe un dispose que puede matarla cuando toque.

Merece la pena entender por qué el aviso solo aparece en desarrollo y no en producción. No es un error recuperable ni una condición que Solid pueda arreglar por ti: es un recordatorio dirigido al programador de que ha creado vida reactiva sin firmar su muerte. En el build de producción el mensaje se elimina para no pesar, pero la fuga que denunciaba seguiría ahí, silenciosa. Tratar ese aviso como ruido y acallarlo es, muy literalmente, apagar el detector de humos porque suena.

⚠️
Declara el parámetro dispose o la raíz no será disponible

Hay un detalle de implementación que muerde. Si escribes createRoot(() => …) sin declarar el parámetro, Solid detecta que la función no recibe argumentos y usa un owner compartido interno en lugar de crear una raíz propia y disponible; nunca obtienes un dispose para ese ámbito. La regla práctica es tajante: siempre que quieras poder cerrar el scope, declara el parámetro, aunque sea para reexportarlo —createRoot(dispose => …)—. Solo la presencia del argumento le pide a Solid que fabrique una raíz de verdad, con su lista de hijos y su llave de destrucción.

Qué hereda y qué corta

La raíz no queda del todo aislada: hereda el contexto del owner en cuyo seno la creaste, de modo que useContext dentro de ella sigue viendo los mismos proveedores. Lo que corta es la disposición automática: aunque exista ese vínculo de contexto con el owner de arriba, la raíz no se apunta en la lista de hijos de nadie, así que destruir al padre no la destruye a ella. Ese es justo el sentido de «raíz»: una vida que decides tú, no el árbol que la rodea.

En la práctica, esa herencia de contexto es lo que permite fabricar una raíz dentro de un componente y seguir consumiendo sus proveedores sin más ceremonia:

import { createRoot, useContext, onCleanup } from "solid-js";

function dentroDeUnComponente() {
  // La raiz hereda el contexto del componente que la abre...
  const dispose = createRoot((d) => {
    const tema = useContext(TemaContext); // ...por eso esto resuelve bien
    createEffect(() => document.body.dataset.tema = tema());
    return d;
  });
  onCleanup(dispose); // pero su muerte hay que atarla a mano
}

Contexto sí, muerte no: esa asimetría exacta es la que hace de createRoot la herramienta correcta para ámbitos de larga vida que aun así deben respetar los proveedores vigentes, y la que te obliga, a cambio, a coser su disposición donde corresponda.

La misma lógica se aplica hacia dentro cuando anidas raíces. Nada impide abrir una raíz dentro de otra; es más, cada componente de tu aplicación ya es una raíz anidada bajo la de render, aunque el runtime la gestione por ti. Cuando anidas a mano, recuerda la asimetría del apartado anterior aplicada hacia dentro: una raíz interna no se dispone cuando se dispone la externa, porque tampoco figura en su lista de hijos. Si quieres que la interna muera con la externa, cóselas con onCleanup.

createRoot((disposeExterno) => {
  // Esta raiz interna sobreviviria a disposeExterno si no la atamos:
  const disposeInterno = createRoot((d) => {
    createEffect(() => {/* ... */});
    return d;
  });
  onCleanup(disposeInterno); // ahora si: muere junto con la externa
  return disposeExterno;
});

Esta es la excepción que confirma la regla, y la razón de que un componente hijo sí muera con su padre: el runtime, al montar, teje esa costura por ti en cada nivel del árbol. Fabricar raíces a mano es, sencillamente, aceptar tejerla tú donde haga falta.

Toda reactividad viva cuelga de una raíz que alguien abrió

El recorrido de niveles anteriores te dio owners sin que los pidieras, y esa comodidad esconde una verdad estructural que createRoot desnuda: en Solid no hay computación de larga vida sin un owner, y no hay owner de larga vida sin alguien que lo abriera y se comprometiera a cerrarlo. El componente parecía el dueño natural del estado, pero el componente solo era, otra vez, un createRoot que el runtime abría por ti al montar y cerraba por ti al desmontar. Cuando fabricas la raíz a mano, asumes el rol que el framework venía cubriendo en silencio: eres tú quien traza los límites del ámbito, quien decide qué computaciones nacen dentro y, sobre todo, quien firma el contrato de su muerte. De ahí la disciplina que arrastra el resto del nivel: cada createRoot que abres es una promesa de dispose. Y de ahí también la claridad conceptual que regala, porque al separar poseer de rastrear entiendes por fin que el árbol de ownership y el grafo de dependencias son dos estructuras distintas superpuestas: una gobierna cuánto viven las cosas, la otra quién reacciona a quién. Los componentes las presentaban fundidas; createRoot te las entrega separadas, y con ellas separadas ya puedes construir estado global disponible, montar herramientas fuera del árbol, escribir tests deterministas y razonar sobre memoria como lo que es: la gestión explícita de las raíces que abres.

⚔️ Abre y cierra un ámbito a mano
  1. Crea con createRoot un contador con su efecto de log; devuelve setCount y dispose, cambia el valor, dispón y comprueba que el efecto deja de reaccionar.
  2. Lee una señal en el nivel superior de la raíz y verifica que no rastrea: cámbiala después y observa que el cuerpo no vuelve a correr; solo el efecto interno lo hace.
  3. Provoca el aviso creando un createEffect a nivel de módulo, luego enváuelvelo en createRoot y confirma que el mensaje desaparece.
  4. Escribe createRoot(() => …) sin declarar el parámetro y razona por qué no obtienes una raíz disponible; corrígelo declarando dispose.
  5. Argumenta con tus palabras la diferencia entre poseer y rastrear, y por qué la raíz hace lo primero pero no lo segundo.