createRoot para estado global con disposición
Un signal de módulo es un contenedor sin ciclo de vida; en cuanto tu estado global necesita una computación —un memo derivado, un efecto de sincronización— aparece la pregunta del owner. createRoot le da a ese estado una raíz reactiva propia: un dueño al que colgar los cómputos y un dispose para apagarlo entero al cerrar sesión, entre tests o en recarga en caliente. Global deja de significar eterno.
El store de módulo tenía un límite honesto: aguantaba datos, no cómputos. Un signal no necesita dueño porque no observa a nadie, pero un createMemo o un createEffect sí —son nodos del grafo que rastrean dependencias y deben poder destruirse—. Colgarlos de la cima de un módulo los deja huérfanos: corren, pero nadie los limpiará jamás. createRoot resuelve esto dándole a tu estado global una raíz reactiva: un owner explícito bajo el cual las computaciones tienen a quién pertenecer, y un dispose con el que apagar el conjunto entero. Es el paso de un estado global inmortal a uno gobernable.
- Diagnosticar por qué un memo o un efecto en el ámbito de módulo queda sin dueño y se filtra.
- Envolver el estado global en
createRootpara darle un owner y una función de disposición. - Inicializar el singleton de forma perezosa para no construir reactividad antes de tiempo.
- Usar el
disposepara apagar el estado global al cerrar sesión, entre tests y en HMR.
El problema: computaciones sin dueño
Recordemos el diagnóstico de la lección anterior con precisión. Un signal es un contenedor: guarda un valor y notifica a quien lo lea. No tiene ciclo de vida —nada que limpiar— y por eso vive sin problemas en un módulo. Una computación es otra cosa: un createMemo cachea y rastrea; un createEffect reacciona y a veces reserva recursos. Ambos son nodos que deben poder disponerse, y para disponerse necesitan un dueño que los tenga registrados.
// AVISA: computacion creada fuera de una raiz, nunca se dispondra
import { createSignal, createEffect } from "solid-js";
const [usuario, setUsuario] = createSignal<string | null>(null);
createEffect(() => {
localStorage.setItem("usuario", usuario() ?? "");
});
Ese efecto funciona, pero Solid advierte en desarrollo que se creó fuera de un createRoot o render. La advertencia no es pedante: describe una fuga real. El efecto quedó sin owner, así que nada lo destruirá nunca; si el módulo se recarga en caliente, o si en un test montas y desmontas la aplicación varias veces, se acumulan efectos vivos suscritos al mismo signal, cada uno escribiendo en localStorage a la vez. Un owner es, literalmente, la respuesta a la pregunta «¿quién limpiará esto?». En el ámbito de módulo esa pregunta no tiene respuesta.
createRoot da un owner y un dispose
createRoot abre una raíz reactiva: crea un owner explícito, ejecuta tu callback bajo él, y te entrega un dispose para destruirlo con todo lo que cuelgue de él. Al construir tu estado global dentro de esa raíz, cualquier memo o efecto que crees pasa a tener dueño —y por tanto, un final.
// store/sesion.ts
import { createRoot, createSignal, createMemo, createEffect } from "solid-js";
function construir() {
return createRoot((dispose) => {
const [usuario, setUsuario] = createSignal<string | null>(null);
const conectado = createMemo(() => usuario() !== null); // memo con dueño
createEffect(() => { // efecto con dueño
localStorage.setItem("usuario", usuario() ?? "");
});
return {
usuario,
conectado,
entrar: (nombre: string) => setUsuario(nombre),
salir: () => setUsuario(null),
dispose,
};
});
}
let instancia: ReturnType<typeof construir> | null = null;
export function usarSesion() {
return (instancia ??= construir()); // perezoso: una sola raiz
}
Ahora el memo conectado y el efecto de persistencia pertenecen a la raíz del singleton. No hay advertencia, porque hay dueño; y hay un dispose en la mano, porque el dueño es explícito. La inicialización perezosa —instancia ??= construir()— no es un adorno: retrasa la creación de toda esa reactividad hasta que alguien la pide de verdad, lo que evita pagar por lo que no se usa y, como veremos en la lección de SSR, te da el punto de control donde decidir si la instancia debe ser por proceso o por petición.
flowchart TD A[createMemo en el modulo] --> B[sin owner: advertencia y fuga] C[createRoot abre una raiz] --> D[owner explicito del singleton] D --> E[memos y efectos cuelgan del owner] D --> F[dispose apaga la raiz entera] style A fill:#f38ba8,color:#11111b style C fill:#a6e3a1,color:#11111b style F fill:#89b4fa,color:#11111b
Conviene ver por qué «a veces hace falta un owner» y a veces no, sin misticismo. El owner cumple tres funciones concretas: es a quien se registran los onCleanup, es el ancestro que propaga el contexto de createContext, y es la raíz que, al disponerse, destruye en cascada todo su subárbol. Un signal puro no participa de ninguna de las tres, así que no necesita owner. En cuanto tu estado global incorpora una derivación memoizada, un efecto, una suscripción a una API del navegador o consumo de contexto, entra en juego al menos una de esas tres funciones, y entonces el owner deja de ser opcional.
Dentro de un componente nunca ves esta advertencia porque render abrió un createRoot en la raíz de la app, y cada componente es un owner hijo de esa raíz. Todo lo que creas ahí ya tiene dueño heredado. El estado global es especial precisamente porque vive fuera de ese árbol —en un módulo, no en un componente— y por eso es el único sitio donde tú, a mano, tienes que abrir la raíz que en el resto de la aplicación abre render por ti.
Disposición controlada: logout, tests y HMR
La recompensa del owner explícito no es solo callar la advertencia: es ganar un interruptor. Cerrar sesión no debería ser únicamente poner el usuario a null; es apagar toda la reactividad de la sesión —el efecto de persistencia, cualquier sondeo, cualquier suscripción— para que nada del usuario anterior sobreviva al siguiente. Con la raíz disponible, es un gesto.
export function cerrarSesion() {
instancia?.dispose(); // destruye la raiz y todo lo colgado de ella
instancia = null; // el proximo usarSesion() construye una raiz limpia
}
Tras dispose, el efecto de persistencia deja de correr y el siguiente usarSesion() reconstruye un ámbito virgen. El mismo par de líneas resuelve otros dos escenarios que, sin owner, son focos de fugas. En tests, disponer entre casos garantiza aislamiento: ningún efecto del test anterior sigue vivo contaminando el siguiente. En recarga en caliente (HMR), disponer la instancia previa antes de que el módulo se reemplace evita que se apilen raíces fantasma recarga tras recarga.
El aislamiento entre tests se escribe una vez y protege todos los casos, con la misma pareja de líneas dentro de un gancho de limpieza:
import { afterEach } from "vitest";
afterEach(() => {
instancia?.dispose(); // cada test arranca con una sesion virgen
instancia = null;
});
Sin este gancho, el primer test que llame a entrar deja un usuario y un efecto de persistencia vivos que el segundo test heredaría, produciendo fallos fantasma que dependen del orden de ejecución —la peor clase de test inestable—. Con él, cada caso encuentra una raíz limpia. La guarda de recarga en caliente es el mismo gesto trasladado al desarrollo, y vive en el propio módulo del store:
// Cierra la raiz previa cuando el bundler recarga este modulo en caliente.
if (import.meta.hot) {
import.meta.hot.dispose(() => {
instancia?.dispose();
instancia = null;
});
}
Que el mismo mecanismo —dispose y vuelta a null— cubra el logout del usuario, el aislamiento entre tests y la higiene del desarrollo en caliente no es casualidad: es la señal de que has dado con la primitiva correcta en lugar de con tres parches distintos. Un buen diseño se reconoce en eso.
Un createRoot disponible resuelve el ciclo de vida en el navegador, donde cada pestaña es un programa propio. En un servidor SSR, la variable instancia es única para todas las peticiones del proceso: el problema no es que no puedas destruirla, sino que se comparte mientras vive, filtrando estado de un usuario a otro. La disposición gobierna cuándo muere el estado, no entre quiénes se comparte. Reserva este patrón para el cliente; el estado por usuario en el servidor es el tema de la última lección de este nivel.
El estado de módulo pelado fundía por accidente dos propiedades que la intuición confunde con facilidad: ser global —accesible desde toda la aplicación— y ser eterno —vivir mientras viva el programa—. Un signal en la cima de un archivo es ambas cosas a la vez, y como nunca necesita destruirse, la confusión pasa inadvertida. createRoot es la herramienta que separa esas dos dimensiones a propósito. El alcance de un estado —quién puede verlo— y su vida —cuánto tiempo existe— son ejes independientes, y confundirlos es la raíz de casi todas las fugas de estado global. Puedes querer algo visible desde todas partes pero destruible bajo demanda: una sesión de usuario es global mientras dura, pero debe morir limpiamente al cerrar sesión. Puedes querer algo local en el árbol pero longevo. El módulo solo te ofrecía la esquina «global y eterno»; createRoot te devuelve las otras. Al darle a tu estado global un owner explícito, dejas de estar preso de la vida del proceso y pasas a gobernar la vida del estado con una palabra, dispose. La lección profunda es que un singleton que puedes matar es más honesto que uno inmortal: no finge que su alcance amplio le obliga a una vida infinita. Declara que es amplio en el espacio y, a la vez, acotado en el tiempo —exactamente lo que la mayoría del estado real necesita, y exactamente lo que un signal de módulo solo no podía expresar—.
- Parte de un store de módulo con un
createEffectde persistencia en la cima y provoca la advertencia de desarrollo; describe la fuga que anuncia. - Envuelve el estado en
createRoot, mueve el efecto y uncreateMemoderivado dentro, y confirma que la advertencia desaparece. - Expón el singleton con inicialización perezosa (
instancia ??= construir()) y verifica que dos componentes reciben la misma instancia. - Implementa
cerrarSesioncondisposey vuelta anull; comprueba que el efecto de persistencia deja de correr y que el siguiente uso reconstruye una raíz limpia. - Explica con las tres funciones del owner —cleanup, contexto, disposición en cascada— por qué un signal no necesita raíz pero un efecto sí.