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 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 dispondraimport { 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.tsimport { 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.
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.
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.