wandres.dev
ESTADO EN SSR · serializar e hidratar

Lo que solo existe en el cliente

El servidor renderiza en un entorno deliberadamente amputado: no hay `window`, no hay `document`, no hay `localStorage`, no hay tamaño de ventana ni preferencia de tema del sistema. Cualquier estado que dependa de esas fuentes es, desde el punto de vista del servidor, incognoscible, y esta lección desarrolla la disciplina completa para tratarlo. Empieza por descartar el reflejo más común y más dañino —comprobar la existencia de `window` durante el render, que produce dos salidas distintas para el mismo componente y rompe la adopción del árbol—, y lo sustituye por la regla del valor neutro: renderizar siempre lo que el servidor renderizaría y corregir un fotograma después. Sobre esa base recorre las cuatro técnicas de ajuste ordenadas por lo que cada una concede: mover el dato a una cookie, resolverlo sin estado con CSS, corregirlo tras montar con una lectura suscrita, o renunciar al servidor para esa isla concreta. Termina resolviendo de punta a punta el caso más pedido, el tema oscuro sin destello, y explicando por qué su solución canónica funciona.

⏱ 18 min

Hay una categoría de estado que ninguna de las técnicas anteriores puede rescatar, porque el problema no es de transporte sino de existencia. Puedes deshidratar lo que el servidor calculó y puedes aislar por petición lo que pertenece a cada usuario, pero no puedes enviar al cliente algo que el servidor nunca tuvo. El ancho de la ventana, la preferencia de tema del sistema operativo, el contenido de localStorage, la zona horaria del navegador, si el usuario está en línea, qué hay en el portapapeles: nada de eso existe en el proceso que genera el HTML, y no por una limitación técnica que alguna versión futura vaya a resolver, sino porque son propiedades del dispositivo que hace la petición y el servidor solo recibe de él lo que quepa en una cabecera HTTP. El reflejo inmediato ante ese vacío es preguntar si estamos en el navegador y ramificar, y ese reflejo es precisamente lo que hay que desaprender, porque produce un componente que devuelve una cosa en el servidor y otra en el cliente, que es la definición exacta de lo que rompe la adopción del árbol. La disciplina correcta invierte el orden: primero se decide qué renderiza el servidor, ese valor se renderiza también en el cliente en el fotograma cero, y solo después se corrige. Toda la sofisticación está en elegir bien el valor neutro y en decidir cuánto tiempo puede vivir la interfaz mostrándolo.

🎯 Al terminar esta lección sabrás
  • Enumerar qué información del dispositivo es inaccesible para el servidor y por qué la cabecera de la petición es su único canal.
  • Rechazar la comprobación de entorno durante el render y aplicar en su lugar la regla del valor neutro.
  • Elegir entre las cuatro técnicas de ajuste según lo que cada una concede en HTML inicial, parpadeo y complejidad.
  • Resolver el tema oscuro sin destello y explicar por qué el script bloqueante no provoca divergencia.

El entorno amputado y el único canal abierto

El proceso que renderiza en el servidor es un entorno de JavaScript sin interfaz de usuario. No es que las APIs del navegador estén vacías: es que no existen, porque pertenecen a una plataforma distinta. Lo único que el servidor sabe del cliente es lo que este le contó al pedir la página, y eso cabe en una lista corta: la URL con sus parámetros, las cabeceras estándar —idiomas aceptados, agente de usuario, sugerencias del cliente si están habilitadas— y las cookies.

De ahí se deduce un criterio operativo muy limpio. Todo dato del dispositivo pertenece a una de dos clases: los que pueden llegar por ese canal y los que no. El idioma preferido llega por cabecera. Una preferencia guardada puede llegar si la escribes en una cookie en lugar de en localStorage. El ancho exacto de la ventana no llega nunca. La preferencia de tema del sistema tampoco, salvo que el navegador envíe la sugerencia correspondiente y tú la negocies explícitamente.

La consecuencia práctica es que la primera decisión de diseño no es cómo detecto esto en el cliente, sino puedo mover esta información al canal que el servidor sí escucha. Cada dato que consigues trasladar deja de ser un problema de hidratación y pasa a ser un dato normal, disponible desde el primer byte.

Ese traslado tiene un precio que conviene conocer antes de abusar de él. Una cookie viaja en cada petición al mismo origen, incluidas las de recursos estáticos si no restringes su ámbito, así que engorda todas las subidas y no solo la del documento. Además, cualquier respuesta que dependa de una cookie deja de ser cacheable de forma compartida salvo que declares explícitamente esa variación. Mover un dato al canal del servidor convierte un problema de hidratación en un problema de cache, y la segunda conversación suele ser más barata, pero hay que tenerla.

// El servidor lee lo que la peticion le cuenta, nada mas
const idioma = peticion.headers.get('accept-language')?.split(',')[0]
const tema = leerCookie(peticion, 'tema') ?? 'claro'
const ancho = null // no existe: ninguna cabecera lo transporta
⚠️
La comprobación de entorno en render es el antipatrón, no la solución

Ramificar durante el render según si existe window parece resolver el error de ejecución en el servidor, y en efecto lo resuelve, pero a cambio garantiza el problema mayor: el componente produce una salida en el servidor y otra distinta en el primer render del cliente, que es exactamente la divergencia que impide adoptar el árbol. La comprobación de entorno es legítima para proteger un efecto o una función que solo se ejecuta tras montar; nunca para decidir qué se renderiza.

La regla del valor neutro

La disciplina cabe en una frase: el primer render del cliente debe ser idéntico al del servidor, así que empieza por elegir qué renderiza el servidor cuando no sabe. Ese valor por defecto no es un relleno arbitrario; es una decisión de producto que conviene tomar a conciencia, porque muchos usuarios lo verán durante unos milisegundos y algunos lo verán durante bastante más si su conexión es lenta.

Hay un cuarto criterio que solo aparece cuando el equipo lleva tiempo con SSR: el valor neutro debe ser explicable. Alguien recibirá un informe de que la página se ve rara durante un instante, y la respuesta debe poder ser esto es el valor por defecto que servimos mientras no conocemos el dispositivo, no vaya usted a saber. Escribirlo como constante con nombre, en lugar de como número suelto dentro de un componente, convierte una rareza inexplicable en una decisión documentada.

Un buen valor neutro cumple tres condiciones. Es válido por sí mismo, en el sentido de que la interfaz no parece rota mientras se muestra. Es el más probable, para que la corrección posterior afecte a la minoría. Y ocupa el mismo espacio que el valor real, para que el ajuste no desplace el contenido y provoque un salto visual.

// Valor neutro en el primer render, correccion suscrita despues
const anchoNeutro = 1024

const ancho = useSyncExternalStore(
  suscribirAResize,            // alta y baja del listener
  () => window.innerWidth,     // instantanea real en el cliente
  () => anchoNeutro,           // instantanea en el servidor
)

Declarar la instantánea de servidor de forma explícita, como en el ejemplo, es preferible a una bandera de montado porque hace visible en el propio código cuál es el valor neutro elegido, en lugar de esconderlo en el estado inicial de un componente. El resultado observable es el mismo; la diferencia está en que el contrato queda escrito.

La tercera condición, la del espacio ocupado, es la que más se descuida y la que más daña la experiencia percibida. Si el valor neutro produce un bloque de altura distinta a la del valor real, la corrección empuja todo lo que hay debajo y el usuario ve saltar la página justo cuando iba a leer o a pulsar. Reservar el hueco desde el primer render —con una altura mínima, una proporción fija o un esqueleto del mismo tamaño— convierte un salto visible en un cambio de contenido que casi nadie percibe.

flowchart TD
S[servidor renderiza el valor neutro] --> H[HTML entregado y adoptado sin divergencia]
H --> M[componente montado en el cliente]
M --> L[lectura de la fuente real del navegador]
L --> Q{coincide con el valor neutro}
Q -->|si| Q1[nada cambia y el usuario no percibe nada]
Q -->|no| Q2[actualizacion en el segundo fotograma]
style Q1 fill:#a6e3a1,color:#11111b
style Q2 fill:#f9e2af,color:#11111b

Cuatro maneras de ajustar, ordenadas por lo que conceden

🍪

Mover el dato a la cookie

Convierte un dato incognoscible en un dato de la petición. El servidor pinta el valor correcto desde el primer byte y no hay ni divergencia ni parpadeo. Es la mejor opción siempre que el dato sea una preferencia persistente.

🎨

Resolverlo sin estado, con CSS

El ancho de la ventana y la preferencia de tema del sistema son consultas de medios. Si la interfaz puede adaptarse con reglas de estilo en lugar de con una variable de JavaScript, el estado desaparece y con él todo el problema.

🔁

Corregir tras montar

Valor neutro en el fotograma cero y lectura suscrita después. Se acepta un ajuste visible a cambio de conservar el HTML servido. Es la opción por defecto para lo que no cabe en una cookie ni en una regla de estilo.

🚪

Renunciar al servidor

No renderizar esa isla en el servidor en absoluto. Elimina la divergencia por construcción pero sacrifica su HTML inicial. Reservado para lo intrínsecamente ligado al navegador: un mapa, un editor rico, un lienzo.

Merece subrayarse la segunda, porque es la que más se olvida y la única que elimina el problema en lugar de gestionarlo. Muchísimo estado de cliente existe únicamente porque alguien decidió resolver con JavaScript algo que el motor de estilos ya sabe hacer sin ayuda. Un valor de punto de ruptura leído del ancho de la ventana casi siempre puede sustituirse por una consulta de medios o de contenedor; el tema que sigue al sistema operativo puede resolverse con una consulta de esquema de color. Donde no hay variable no hay hidratación que romper, ni valor neutro que elegir, ni ajuste que temporizar.

La cuarta opción merece una advertencia porque su coste se paga en un lugar donde nadie mira. Sacar una isla del servidor no solo elimina su HTML inicial: retrasa su aparición hasta que el JavaScript se descarga, se analiza y se ejecuta, lo que en una conexión mediocre son segundos, no milisegundos. Si esa isla ocupa la parte visible de la pantalla, el usuario mira un hueco durante todo ese tiempo, y las métricas de estabilidad visual y de mayor elemento pintado lo registran. Es una herramienta legítima para un widget que de todos modos no aporta al primer fotograma, y una mala decisión para casi todo lo demás.

💡
Antes de elegir técnica, pregunta si el estado tiene que existir

La secuencia de preguntas que conviene aplicar a cada dato del navegador es siempre la misma y en este orden. Primero, se puede resolver con CSS sin variable alguna. Si no, se puede mover a una cookie para que el servidor lo conozca. Si tampoco, se puede renderizar un valor neutro aceptable y corregirlo tras montar. Y solo si las tres respuestas son negativas se plantea sacar esa isla del servidor. Empezar por el final es lo que llena las aplicaciones de componentes que se montan tarde y de pantallas que aparecen a trozos.

El tema oscuro, resuelto de punta a punta

El tema es el caso de estudio perfecto porque combina las tres fuentes posibles y porque el fallo es visible a simple vista: el destello blanco antes de que el tema oscuro se aplique. La preferencia puede venir del sistema operativo, mediante una consulta de medios; de una elección explícita del usuario, guardada en algún sitio; o del valor por defecto de la aplicación cuando no hay ninguna de las dos.

La solución canónica no es un componente sino un fragmento síncrono ejecutado en el encabezado del documento, antes de que el navegador pinte el primer píxel. Lee la preferencia guardada, cae en la consulta de medios si no hay ninguna, y escribe el resultado como atributo en el elemento raíz.

// En el head, antes de cualquier render del framework
const guardado = localStorage.getItem('tema')
const sistema = matchMedia('(prefers-color-scheme: dark)').matches
document.documentElement.dataset.tema = guardado ?? (sistema ? 'oscuro' : 'claro')

Conviene entender por qué esto no provoca divergencia, porque parece contradecir todo lo dicho. La clave es que el fragmento no participa en el árbol que el framework va a adoptar: escribe un atributo en el elemento raíz del documento, que está fuera del subárbol hidratado, y lo hace antes de que el framework siquiera arranque. Cuando llega la hidratación, el árbol servido y el calculado siguen coincidiendo, porque ninguno de los dos menciona el tema; el tema vive en CSS, gobernado por ese atributo. Se paga un script bloqueante de microsegundos y se compra la ausencia total de destello.

Si además el usuario puede cambiar el tema desde la interfaz, esa elección se escribe en el atributo y, si quieres que el servidor la conozca en futuras visitas, también en una cookie. A partir de ahí el servidor pinta el atributo correcto desde el primer byte y el fragmento del encabezado queda como red de seguridad para la primera visita, cuando aún no había cookie.

El resultado final tiene una propiedad que conviene reconocer porque es el objetivo de todo el nivel: no hay ningún estado de tema en el árbol hidratado. El tema es un atributo del documento leído por reglas de estilo, la elección del usuario es una escritura en dos sitios y el componente que la ofrece no tiene que renderizar nada distinto en servidor y en cliente. Eliminar el estado, cuando se puede, es siempre mejor que sincronizarlo bien.

ℹ️
Los medios de impresión y las preferencias de accesibilidad también son del cliente

El tema oscuro es el ejemplo famoso, pero la misma familia incluye la preferencia de movimiento reducido, la de contraste alto, la de transparencia y el modo de impresión. Todas son consultas de medios y ninguna llega al servidor, así que la tentación de leerlas con JavaScript y guardarlas en estado reaparece intacta. La respuesta es idéntica y aún más clara aquí: resuélvelas en la hoja de estilos. Una animación que se desactiva con una consulta de movimiento reducido no necesita ninguna variable, funciona antes de que cargue el JavaScript y jamás produce divergencia.

El servidor no es un cliente pobre: es un observador distinto

La forma habitual de contar esto —el servidor no tiene window, así que hay que apañarse— sugiere que el servidor es una versión mutilada del navegador y que nuestro trabajo consiste en compensar sus carencias. Esa metáfora produce malas decisiones, porque invita a fingir que el servidor sabe cosas que no sabe. La metáfora correcta es otra: el servidor y el cliente son dos observadores distintos del mismo sistema, y cada uno tiene acceso a información que el otro no puede tener. El servidor conoce la base de datos, los secretos, la sesión verificada y el estado compartido por todos los usuarios; el cliente conoce el dispositivo, la ventana, las preferencias locales y lo que el usuario acaba de hacer con el ratón. Ninguno es una versión degradada del otro: tienen dominios de conocimiento disjuntos que se solapan solo en lo que la petición HTTP consigue transportar. Vista así, la disciplina de esta lección deja de ser una lista de trucos y se convierte en una pregunta de diseño de sistemas: qué información necesita cada observador para hacer su parte, y cuál es el canal por el que puede llegarle. Deshidratar es abrir un canal del servidor hacia el cliente para el conocimiento que solo el servidor tiene. La cookie es el canal inverso, estrecho y anticipado, para el conocimiento del dispositivo que queremos que el servidor tenga. El valor neutro es la respuesta honesta a lo que ningún canal transporta: una declaración explícita de lo que renderizamos mientras no sabemos, en lugar de una mentira que luego hay que desmentir. Y la mejor solución de todas sigue siendo la que elimina la pregunta: cuando el motor de estilos puede adaptarse solo, no hace falta que ningún observador informe al otro de nada.

⚔️ Elimina el destello y el estado innecesario
  1. Inventaria en tu aplicación todos los datos que solo existen en el navegador y clasifícalos en transportables por cookie o cabecera y no transportables.
  2. Localiza cada comprobación de existencia de window que ocurra durante un render y sustitúyela por un valor neutro explícito con su instantánea de servidor.
  3. Elige a conciencia el valor neutro de cada caso comprobando las tres condiciones: que sea válido, que sea el más probable y que no desplace el contenido al corregirse.
  4. Sustituye al menos un estado leído del ancho de la ventana por una consulta de medios o de contenedor, y comprueba que el componente ya no necesita ninguna variable.
  5. Implementa el tema con el fragmento síncrono del encabezado y verifica que no hay destello en la primera carga ni aviso de hidratación.
  6. Añade la cookie de tema, comprueba que el servidor ya sirve el atributo correcto en la segunda visita y explica qué papel sigue cumpliendo el fragmento del encabezado.