wandres.dev
ESTADO GLOBAL · stores compartidos

Estado global y SSR

En el navegador un módulo dura lo que la pestaña: su ámbito coincide con un solo usuario. En un servidor SSR el mismo módulo se evalúa una vez por proceso y se comparte entre todas las peticiones, así que un singleton de módulo con estado por usuario filtra datos de un cliente a otro. El patrón correcto en SolidStart es crear ese estado dentro del árbol —en la raíz, por contexto— para que cada petición tenga el suyo.

⏱ 18 min

Todos los patrones de estado global de este nivel comparten un supuesto silencioso: que un módulo dura lo mismo que un usuario. En el navegador es cierto —cada pestaña es un proceso propio, y el módulo vive y muere con ella—. En el servidor deja de serlo de golpe. Un proceso SSR atiende a muchos usuarios a la vez, evalúa cada módulo una sola vez y comparte sus variables entre todas las peticiones. Ahí, un singleton de módulo que guarde el usuario actual no es un patrón cómodo: es una fuga de datos entre clientes. Esta lección explica por qué ocurre y cuál es el patrón correcto en SolidStart para tener estado global sin filtrar estado entre peticiones.

🎯 Al terminar esta lección sabrás
  • Entender que un módulo se evalúa una vez por proceso y se comparte entre todas las peticiones.
  • Reconocer la fuga de estado entre peticiones como un fallo de seguridad, no solo un bug.
  • Aplicar el patrón correcto en SolidStart: estado por petición provisto en la raíz del árbol.
  • Distinguir qué estado sí puede ser global al proceso: constantes, cachés y recursos compartidos.

Un módulo se comparte entre peticiones

El sistema de módulos evalúa el cuerpo de un archivo la primera vez que se importa y reutiliza sus exportaciones para siempre. En el cliente eso te dio el singleton reactivo gratis: una pestaña, un módulo, un usuario. En el servidor, la misma mecánica se vuelve una trampa. El proceso que sirve tu aplicación evalúa el módulo una vez al arrancar y lo mantiene vivo mientras dure el proceso, atendiendo con esas mismas variables a cada petición que llega.

// store/sesion.ts  —  PELIGROSO EN EL SERVIDOR
import { createSignal } from "solid-js";

const [usuario, setUsuario] = createSignal<string | null>(null);

export { usuario };
export const entrar = (nombre: string) => setUsuario(nombre);

Imagina dos peticiones casi simultáneas. La de Ana renderiza, llama a entrar("Ana") y usuario() pasa a "Ana". Un instante después llega la de Beto; su render lee usuario() y encuentra… "Ana", porque usuario es la misma variable para ambos. El estado de un usuario se ha filtrado en la respuesta de otro. No es un caso raro de concurrencia exótica: es el comportamiento normal de una variable de módulo en un proceso que sirve a muchos, y basta cualquier solapamiento de peticiones para dispararlo.

flowchart TD
P[proceso SSR arranca una vez] --> M[modulo sesion evaluado una vez]
M --> V[variable usuario compartida]
A[peticion de Ana] --> V
B[peticion de Beto] --> V
V --> L[Beto lee el usuario de Ana: fuga]
style M fill:#f9e2af,color:#11111b
style V fill:#f38ba8,color:#11111b
style L fill:#f38ba8,color:#11111b
⚠️
Filtrar estado entre peticiones es un fallo de seguridad

Esto no es un bug de esos que producen una interfaz rara: es una vulnerabilidad. Que la sesión de un usuario, su carrito, su token o sus datos personales aparezcan en la respuesta servida a otro es exactamente la clase de fallo que acaba en un informe de incidente. Y es especialmente insidioso porque en desarrollo casi nunca se reproduce: con una sola pestaña y peticiones espaciadas, el singleton parece funcionar de maravilla. La fuga solo emerge bajo carga real, con peticiones concurrentes, que es precisamente cuando más caro sale descubrirla. Por eso la regla se aprende antes, no después: nunca guardes estado por usuario en una variable de módulo si tu aplicación renderiza en el servidor.

Y ojo: darle una raíz con createRoot, como hicimos para el ciclo de vida en el cliente, no arregla esto. Un createRoot en el ámbito de módulo también se ejecuta una vez por proceso; su dispose gobierna cuándo muere el estado, no entre quiénes se comparte mientras vive. El problema no es la falta de un interruptor de apagado, sino que el estado es único para un proceso que atiende a muchos.

La regla: el estado por petición vive en el árbol

La solución invierte el instinto que traías del cliente. Allí sacabas el estado fuera del árbol de componentes para hacerlo global; aquí, para que sea correcto por petición, tienes que meterlo dentro del árbol. La razón es que, en SSR, el árbol de componentes se re-renderiza en cada petición: cada solicitud construye su propio árbol desde la raíz. Cualquier estado creado dentro de ese árbol nace, por tanto, una vez por petición y muere con ella. Ese es justo el alcance que el estado por usuario necesita.

El vehículo natural para distribuir ese estado por el árbol sin pasarlo por props es el contexto —y aquí, como adelantó la lección anterior, deja de ser opcional—. Creas el store dentro de un componente proveedor cerca de la raíz; como el proveedor se ejecuta una vez por petición, cada petición obtiene una instancia fresca y aislada.

// SesionProvider.tsx  —  una instancia por peticion
import { createSignal, useContext, createContext, type JSX } from "solid-js";

const SesionCtx = createContext<ReturnType<typeof crearSesion>>();

function crearSesion() {
  const [usuario, setUsuario] = createSignal<string | null>(null);
  return {
    get usuario() { return usuario(); },
    entrar: (n: string) => setUsuario(n),
    salir: () => setUsuario(null),
  };
}

export function SesionProvider(props: { children: JSX.Element }) {
  const sesion = crearSesion();                 // se ejecuta por peticion en SSR
  return <SesionCtx.Provider value={sesion}>{props.children}</SesionCtx.Provider>;
}

export const usarSesion = () => useContext(SesionCtx)!;

La factory crearSesion es exactamente el patrón de la lección anterior; lo único que cambia es dónde se la llama: dentro de un proveedor del árbol, no en la cima de un módulo. Ese pequeño desplazamiento —de ámbito de módulo a ámbito de componente— es toda la diferencia entre un estado que se comparte entre usuarios y uno que se aísla por petición.

El patrón correcto en SolidStart

En SolidStart el sitio de ese proveedor es la raíz de la aplicación, típicamente en src/app.tsx, envolviendo el árbol de rutas. Como el componente raíz se ejecuta en cada petición del servidor, el store que crea el proveedor es nuevo por petición en el servidor y único por pestaña en el cliente —el mismo código sirve a ambos mundos, que es justo lo que la factory prometía—.

// src/app.tsx
import { Router } from "@solidjs/router";
import { FileRoutes } from "@solidjs/start/router";
import { Suspense } from "solid-js";
import { SesionProvider } from "./SesionProvider";

export default function App() {
  return (
    <Router
      root={(props) => (
        <SesionProvider>
          <Suspense>{props.children}</Suspense>
        </SesionProvider>
      )}
    >
      <FileRoutes />
    </Router>
  );
}

Para estado que solo vive en el servidor —no reactivo, atado a la petición HTTP en sí, como el usuario autenticado que resuelve un middleware— SolidStart ofrece además getRequestEvent, que devuelve el evento de la petición actual con un locals donde depositar datos por petición sin tocar variables de módulo.

import { getRequestEvent } from "solid-js/web";

// En codigo de servidor: leer datos por peticion, nunca de un modulo compartido.
export function usuarioActual() {
  const evento = getRequestEvent();
  return evento?.locals.usuario ?? null;       // aislado por peticion
}

Y para datos que cargas de forma asíncrona, los primitivos del framework —query del router y createAsync— ya están diseñados para deduplicar y cachear dentro del alcance de una petición, no entre ellas. Si usas esas primitivas para tu estado de datos en lugar de un singleton casero, el aislamiento por petición te lo da el framework sin que tengas que pensarlo. La disciplina, en resumen, es una sola: en SSR, el estado por usuario nunca vive en un módulo; vive en el árbol, en la petición o en las primitivas que ya respetan su alcance.

💡
isServer para ramas que solo tienen sentido en el cliente

Algunos estados globales son intrínsecamente de cliente —un caché en localStorage, un socket, un tema que lee matchMedia—. Para esos, isServer de solid-js/web te deja evitar que esa lógica corra en el servidor, donde ni tiene sentido ni tiene las APIs del navegador. No convierte un singleton peligroso en seguro, pero sí impide que el efecto de un estado pensado para el navegador contamine el render del servidor. Es la guarda que separa lo que es legítimamente de proceso de lo que es legítimamente de pestaña.

Qué sí puede ser global al proceso

La regla no prohíbe todo estado de módulo en el servidor; prohíbe el estado por usuario. Hay categorías enteras de estado para las que ser único por proceso no es un defecto sino la intención: precisamente porque no pertenecen a ningún usuario, compartirlas entre peticiones es correcto y hasta deseable.

📐

Constantes y configuración

Valores derivados del entorno, tablas de consulta de solo lectura, flags de compilación. Iguales para todas las peticiones por definición.

🗄️

Recursos compartidos

Un pool de conexiones a la base de datos, sentencias preparadas, un cliente de un servicio externo. Se comparten a propósito entre peticiones.

⏱️

Cachés de proceso

Una caché de datos públicos no personalizados —listas, catálogos— con su clave e invalidación. Global al proceso, jamás con datos de un usuario.

El criterio que separa lo permitido de lo prohibido es limpio: pregúntate si el valor pertenece a un usuario. Si la respuesta es sí —su sesión, su carrito, sus permisos, cualquier cosa que cambie según quién pide—, ese estado tiene que vivir por petición. Si la respuesta es no —una constante, un recurso de infraestructura, datos públicos iguales para todos—, entonces ser global al proceso es exactamente lo que quieres, y un módulo es su hogar correcto.

Global es relativo al límite que el módulo abraza

La palabra «global» ha viajado por todo este nivel como si su significado fuera absoluto, y la lección final es que no lo es: «global» significa siempre «único dentro de cierto límite», y ese límite lo fija el entorno de ejecución, no el código. En el navegador, el límite de un módulo coincide con el de un usuario: una pestaña ejecuta un solo programa para una sola persona, así que «global al módulo» y «global al usuario» son la misma frontera, y por eso el singleton de las primeras lecciones funcionaba sin sombra de problema. En el servidor esa coincidencia se rompe. El límite de un módulo pasa a ser el del proceso, y un proceso sirve a multitud de usuarios simultáneos; «global al módulo» ahora significa «compartido por todos los usuarios», que es lo contrario de lo que casi cualquier estado quiere. El mismo código, la misma variable, la misma palabra «global» —y un significado opuesto según dónde corra—. Esta es la idea que hay que llevarse del nivel entero: el alcance de tu estado no lo declara el sitio del archivo donde lo escribes, sino la relación entre la vida del módulo que lo contiene y la vida de la unidad que debería poseerlo. Cuando esas dos vidas coinciden —módulo y pestaña en el cliente— el estado de módulo es correcto y elegante. Cuando divergen —módulo y proceso frente a petición y usuario en el servidor— el estado de módulo es una fuga esperando su primera concurrencia. El patrón correcto, meter el estado por usuario en el árbol que se reconstruye por petición, no es más que restaurar esa coincidencia por otros medios: hacer que la vida del estado vuelva a mapear sobre la vida del usuario. Programar SSR con soltura es, en buena parte, no dejar nunca de preguntarse sobre qué límite estás siendo global.

⚔️ Aísla tu estado por petición en SSR
  1. Escribe un singleton de módulo con el usuario actual y razona, con dos peticiones solapadas, cómo el estado de una aparece en la respuesta de la otra.
  2. Explica por qué darle un createRoot al módulo no resuelve la fuga, aunque sí resolviera el ciclo de vida en el cliente.
  3. Refactoriza a un SesionProvider que llame a la factory dentro del árbol y provéelo en la raíz de app.tsx; argumenta por qué ahora cada petición tiene su instancia.
  4. Usa getRequestEvent para leer un dato por petición en código de servidor y contrasta su aislamiento con el de una variable de módulo.
  5. Clasifica cinco estados —sesión, tema, pool de base de datos, catálogo público, token de usuario— en «por petición» o «global al proceso» y justifica cada veredicto con la pregunta de si pertenece a un usuario.