Estado global disposable con getOwner
Un signal de módulo vive para siempre; a veces quieres un singleton reactivo que puedas destruir y recrear —al cerrar sesión, en HMR, entre tests—. createRoot le da una raíz disponible, getOwner captura esa raíz y runWithOwner permite colgar de ella nuevas computaciones que morirán con el singleton.
El estado de módulo era libertad sin ceremonia: declaras un signal en la cima de un archivo y ya es global. Pero esa libertad no tenía marcha atrás —el signal de módulo no muere nunca— y con computaciones dentro tampoco tenía owner. Aquí unimos las dos mitades: createRoot da al singleton una raíz disponible, getOwner captura esa raíz para más tarde, y runWithOwner deja colgar de ella efectos nacidos en otro sitio. El resultado es un estado global que puedes destruir entero y volver a crear limpio: exactamente lo que pide cerrar sesión, recargar en caliente o aislar un test.
- Construir un singleton reactivo dentro de
createRootcon acciones ydispose. - Capturar la raíz del singleton con
getOwnerpara reutilizarla después. - Colgar computaciones externas de esa raíz con
runWithOwner. - Destruir y recrear el estado global sin filtrar suscripciones entre ciclos.
Un singleton dentro de una raíz
El estado global disponible es un módulo que crea su store dentro de createRoot, expone getters y acciones, y guarda además el dispose de la raíz para poder tirar todo abajo. Inicializarlo de forma perezosa evita construirlo antes de que alguien lo use —y, en SSR, antes de que exista una petición.
// store/sesion.ts
import { createRoot, createSignal, getOwner, type Owner } from "solid-js";
type Sesion = {
usuario: () => string | null;
entrar: (nombre: string) => void;
salir: () => void;
owner: Owner | null;
dispose: () => void;
};
let instancia: Sesion | null = null;
export function usarSesion(): Sesion {
if (instancia) return instancia;
instancia = createRoot((dispose) => {
const [usuario, setUsuario] = createSignal<string | null>(null);
return {
usuario,
entrar: (nombre) => setUsuario(nombre),
salir: () => setUsuario(null),
owner: getOwner(), // capturamos la raiz para colgarle efectos luego
dispose,
};
});
return instancia;
}
Cualquier componente que llame a usarSesion() recibe el mismo objeto: un singleton reactivo. La diferencia con un signal de módulo pelado es que este trae un dispose en la mano y una owner capturada.
La inicialización perezosa no es un adorno. Construir el store dentro de usarSesion, y solo la primera vez, significa que ningún efecto arranca hasta que alguien de verdad lo pide: no pagas por lo que no usas. Y, sobre todo, es la cortesía mínima frente a SSR: un módulo que crea su reactividad al importarse la crearía una vez por proceso, compartida entre peticiones; uno que la crea perezosamente te da al menos el punto de control donde decidir si esa instancia debe ser por-proceso o por-petición.
getOwner y runWithOwner
getOwner devuelve el owner activo en el punto donde lo llamas; dentro del callback de createRoot, eso es la raíz del singleton. Guardarlo te da un asa a un ámbito que, de otro modo, quedaría cerrado tras salir del callback. runWithOwner(owner, fn) es la operación inversa: reinstala ese owner, ejecuta fn bajo él y restaura el anterior. Las computaciones creadas dentro de fn pertenecen a la raíz capturada —y, por tanto, mueren cuando el singleton se dispone—, aunque las hayas escrito lejos, en un efecto de arranque o en un módulo distinto.
import { runWithOwner, createEffect } from "solid-js";
// En cualquier parte, mas tarde: registrar un efecto global
// que persista datos, pero atado a la vida del singleton.
const sesion = usarSesion();
runWithOwner(sesion.owner, () => {
createEffect(() => {
const u = sesion.usuario();
localStorage.setItem("usuario", u ?? "");
});
});
Sin runWithOwner, ese createEffect no tendría owner —o pertenecería al componente que lo escribió, muriendo antes de tiempo—. Con él, el efecto cuelga de la raíz del singleton: vive tanto como el estado global y desaparece con él en un solo dispose.
Hay una simetría limpia entre las dos primitivas. getOwner lee el owner activo; runWithOwner lo instala temporalmente para ejecutar algo bajo él. Juntas convierten el owner en un valor de primera clase —guardable, pasable, reutilizable— y rompen la atadura entre dónde escribes una computación y a qué vida pertenece. Es el mismo desacople que createRoot hizo entre poseer y rastrear, ahora aplicado a la dimensión temporal: la posición sintáctica del código deja de fijar su ciclo de vida. Ese desacople es lo que permite un store global cuya raíz vive en un módulo pero que acepta efectos aportados desde componentes, plugins o cualquier rincón, todos condenados a morir juntos cuando llegue el dispose.
Un ejemplo concreto: un sistema de plugins donde cada plugin, al instalarse, aporta efectos de sincronización al store de sesión sin conocer su raíz. El núcleo expone runWithOwner(sesion.owner, fn) como punto de extensión; cada plugin entrega su fn, sus efectos cuelgan de la sesión, y al cerrarla todos se van juntos sin que ningún plugin haya tenido que registrar su propia limpieza. La disposición centralizada deja de ser una convención frágil y pasa a ser una propiedad de la arquitectura.
Una advertencia de higiene, eso sí: repartir el owner crudo en la API pública invita a que cualquiera cuelgue lo que quiera del singleton, y con ello a acoplamientos difíciles de rastrear. En bases de código grandes conviene ofrecer una función acotada —un registrarEfectoDeSesion(fn) que encapsule el runWithOwner— y documentar qué se permite adjuntar, en lugar de exponer el owner desnudo. El owner es un asa poderosa; precisamente por eso merece un mango con forma, no un filo suelto.
flowchart TD C[createRoot abre la raiz del singleton] --> O[getOwner captura esa raiz] O --> S[Signals y acciones del store] E[createEffect escrito en otro modulo] -->|runWithOwner con owner| O D[dispose del singleton] --> K[Muere el store y todo lo colgado] style C fill:#a6e3a1,color:#11111b style O fill:#89b4fa,color:#11111b style D fill:#f38ba8,color:#11111b
Destruir y recrear limpio
La ganancia se cobra en el ciclo de vida. Cerrar sesión no es solo poner el usuario a null: es tirar abajo toda la reactividad de la sesión —efectos de persistencia, sincronizaciones, sondeos— para que nada del usuario anterior sobreviva al siguiente. Con el singleton disponible, es una línea.
export function cerrarSesion() {
instancia?.dispose(); // mata la raiz entera y todo lo colgado de ella
instancia = null; // el proximo usarSesion() construye una raiz nueva
}
Tras dispose, el siguiente usarSesion() encuentra instancia en null y reconstruye un ámbito virgen. Ningún efecto zombi del ciclo anterior queda suscrito. El mismo patrón resuelve el hot module replacement: al recargar el módulo en caliente, dispón la instancia vieja antes de que la nueva la reemplace, y evitarás la acumulación de estados fantasma que ensucia el desarrollo con recargas.
// Cierra la raiz previa cuando el bundler recarga este modulo en caliente.
if (import.meta.hot) {
import.meta.hot.dispose(() => {
instancia?.dispose();
instancia = null;
});
}
Sin esta guarda, cada recarga en caliente dejaría atrás una raíz completa —con sus efectos y suscripciones— viva y suscrita; tras media hora programando tendrías decenas de sesiones fantasma reaccionando a la vez. La disponibilidad no solo sirve para el logout del usuario final: sirve, a diario, para que tu propia sesión de desarrollo no se degrade recarga a recarga.
En los tres casos —cierre de sesión, recarga en caliente, aislamiento entre tests— el gesto es idéntico: dispose y vuelta a null. Que el mismo par de líneas resuelva situaciones tan distintas no es casualidad, sino la señal de que has dado con la primitiva correcta en lugar de con un parche para cada una. Un buen diseño se reconoce por eso: el mismo mecanismo cubre casos que parecían pedir soluciones separadas.
No todo estado global quiere este patrón. Si el estado es en realidad por-árbol —depende de dónde se consume, o puede haber más de una instancia legítima— usa createContext y un proveedor: cada subárbol obtiene la suya y muere con él, sin variables de módulo de por medio. El singleton disponible brilla cuando el estado es genuinamente uno solo para toda la app cliente —la sesión, el tema, una caché compartida— y necesitas además un botón de apagado explícito. La pregunta que decide es simple: ¿cuántas instancias de esto pueden coexistir? Una sola, singleton; varias, context.
Un singleton disponible resuelve el ciclo de vida en el cliente, donde cada pestaña es un programa propio. En un servidor SSR el proceso atiende a muchas peticiones, y instancia es una variable de módulo única para todas ellas: guardar ahí el usuario mezcla sesiones entre peticiones. La disponibilidad no arregla eso —el problema no es que no puedas destruirlo, sino que se comparte mientras vive—. En el servidor, el estado por usuario debe vivir en el ámbito de la petición, vía createContext o los mecanismos de SolidStart. Reserva el singleton disponible para el navegador, o para constantes y cachés que de verdad sean globales al proceso.
getOwner devuelve el owner activo en ese instante. Tras un await, un setTimeout o cualquier salto asíncrono, el contexto reactivo ya se restauró y getOwner devolvería otro owner o null. Por eso el patrón correcto es capturar el owner antes de cruzar la frontera asíncrona y reinstalarlo después: const o = getOwner(); seguido, tras el await, de runWithOwner(o, () => createEffect(…)). Olvidar esto es la causa número uno de efectos que «no reaccionan» o que nacen huérfanos dentro de código async.
Lo que getOwner te entrega no es un objeto cualquiera: es un asa a un tramo de vida. Un owner representa un intervalo de tiempo durante el cual ciertas computaciones tienen sentido y fuera del cual deben dejar de existir. Capturarlo con getOwner es guardar la referencia a ese intervalo; usarlo con runWithOwner es decir «lo que cree aquí pertenece a aquel tramo, no a este». Esta capacidad de escribir código en un sitio pero adscribir su vida a otro es lo que eleva createRoot de truco a herramienta de arquitectura. Con ella dejas de estar preso de la jerarquía sintáctica —donde la vida de un efecto la fijaba el lugar del archivo donde lo tecleaste— y pasas a componer vidas explícitamente: un store global cuya raíz vive lejos de cualquier componente, efectos que se le suman desde módulos que ni se conocen, y una única palabra, dispose, que cierra el intervalo entero y con él todo lo que se le adscribió. La lección profunda es que «global» y «para siempre» no son sinónimos obligados. El estado de módulo los fundía por accidente; createRoot con getOwner los separa a propósito, y te deja tener lo global sin condenarte a lo eterno. Un singleton que puedes matar es, paradójicamente, más honesto que uno inmortal: declara que su alcance es amplio pero su vida, gobernable.
- Construye
usarSesioncomo singleton perezoso dentro decreateRoot; consúmelo desde dos componentes y confirma que comparten usuario. - Captura la raíz con
getOwnery, desde otro módulo, cuélgale conrunWithOwnerun efecto que persista el usuario enlocalStorage. - Implementa
cerrarSesionque llame adisposey ponga la instancia anull; verifica que el efecto de persistencia deja de correr tras cerrar. - Vuelve a llamar a
usarSesiontras cerrar y comprueba que reconstruye una raíz limpia, sin el efecto zombi anterior. - Explica por qué este patrón es correcto en el navegador pero peligroso en SSR, y dónde debería vivir el estado por usuario en el servidor.