wandres.dev
ALTERNATIVAS A XSTATE · Robot, Zag, a mano

Zag.js: máquinas para componentes headless

Detrás de las librerías de componentes modernas no hay componentes: hay máquinas de estado. Zag.js formaliza ese hallazgo con un patrón de tres piezas —la máquina que gobierna el comportamiento, la función connect que proyecta su estado a propiedades de DOM y el normalizador que traduce esas propiedades al dialecto de cada framework— y convierte las prácticas de autoría de WAI-ARIA en artefactos ejecutables y verificables. Esta lección disecciona ese patrón, explica por qué hace portable el comportamiento sin compilador y delimita qué puedes personalizar de una máquina que no diseñaste.

⏱ 18 min

Un menú desplegable accesible tiene alrededor de cuarenta reglas de comportamiento: qué tecla mueve el resaltado, qué pasa al escribir varias letras seguidas, dónde vuelve el foco al cerrar, qué ocurre si el puntero entra mientras el teclado navega, cuándo se cierra al hacer clic fuera y en qué orden si hay dos capas abiertas. Ninguna de esas reglas es visual y todas son estado. Zag.js parte de esa observación y la lleva hasta su conclusión industrial: si el comportamiento de un componente es una máquina, entonces se puede escribir una sola vez, verificar contra una especificación y ejecutar en React, Vue, Solid o Svelte sin reescribirlo. Lo interesante para este nivel no es que Zag sea una alternativa a XState —lo es, y con una implementación propia— sino que demuestra dónde vive el valor real de una máquina en la práctica cotidiana.

🎯 Al terminar esta lección sabrás
  • Reconstruir el patrón de tres piezas que define a Zag y entender el papel de cada una.
  • Leer las prácticas de autoría de WAI-ARIA como especificaciones de máquinas de estado.
  • Separar el comportamiento portable de la presentación local y saber por qué esa frontera es rentable.
  • Decidir entre consumir una máquina de componente, adaptarla en sus bordes o escribir la tuya.

El patrón de tres piezas

Un componente de Zag no se importa como componente: se importa como máquina más proyección. El consumo tiene siempre la misma forma, y esa uniformidad es deliberada porque es lo que permite que un mismo cerebro sirva para cuarenta componentes distintos.

import * as menu from '@zag-js/menu'
import { useMachine, normalizeProps } from '@zag-js/react'

export function useMenu(id: string) {
  const servicio = useMachine(menu.machine, { id })        // 1. la maquina, viva y con identidad
  const api = menu.connect(servicio, normalizeProps)       // 2. proyeccion a propiedades de DOM
  return api                                                // 3. el framework solo reparte props
}

La segunda línea es la que conviene mirar despacio. connect es una función pura del estado actual de la máquina a una descripción de la interfaz: no renderiza nada, no conoce React y no decide etiquetas. Devuelve grupos de propiedades listos para repartir sobre los elementos que tú elijas, con toda la semántica accesible ya resuelta.

const disparador = api.getTriggerProps()
// id: 'menu:v1:trigger'
// aria-haspopup: 'menu'
// aria-expanded: false
// aria-controls: 'menu:v1:content'
// onPointerDown, onKeyDown, onBlur ... enlazados al envio de eventos

Ese objeto es, literalmente, la vista de la máquina en el sentido en que las arquitecturas unidireccionales usan la palabra: una proyección del estado, sin decisiones propias. Y normalizeProps es el adaptador que traduce esa descripción neutra al dialecto de cada framework, porque el mismo concepto se escribe de tres maneras según dónde aterrice: el nombre del manejador de eventos, la propiedad de clase, la forma de los estilos en línea. Esa traducción, que ocupa muy poco código, es lo que hace innecesario un compilador para conseguir portabilidad.

ℹ️
La máquina no es pura, y no puede serlo

A diferencia de las máquinas de dominio que has escrito hasta ahora, la de un componente posee referencias al DOM y ejecuta efectos sobre el documento: mover el foco, capturar el puntero, bloquear el desplazamiento, medir posiciones para colocar una capa flotante. Zag lo asume explícitamente, con efectos asociados a estados que se activan al entrar y se limpian al salir. Es la misma semántica de entrada y salida de Moore que ya conoces, aplicada a un dominio donde el mundo exterior es el árbol de nodos y donde olvidarse de una limpieza deja el foco atrapado o el desplazamiento bloqueado para siempre.

El componente accesible es un protocolo

Aquí está el hallazgo conceptual que justifica todo lo demás. Las prácticas de autoría de WAI-ARIA no describen apariencias: describen estados, eventos y transiciones. Cuando una guía dice que en un menú abierto la flecha abajo mueve el resaltado al siguiente elemento y que al llegar al final vuelve al principio, está escribiendo una tabla de transiciones en prosa. Codificarla como máquina no es una traducción creativa, es una transcripción, y transcribir tiene tres consecuencias inmediatas.

La accesibilidad se vuelve invariante

Las reglas dejan de depender de que cada equipo recuerde aplicarlas. Viven en el grafo y se cumplen por construcción.

🔌

El comportamiento se vuelve portable

La máquina no conoce el framework. Cambiar de React a Solid cambia el adaptador, no el modelo del componente.

🧪

La conformidad se vuelve verificable

Una especificación transcrita como estados y eventos se puede recorrer y probar de forma exhaustiva contra el documento original.

🎨

La presentación queda libre

Si connect solo devuelve propiedades, el marcado y los estilos son tuyos. La máquina no impone ni una clase.

flowchart LR
A[especificacion de autoria accesible] --> B[maquina del componente]
B --> C[estado actual y envio de eventos]
C --> D[connect proyecta propiedades neutras]
D --> E[normalizeProps traduce al framework]
E --> F[React]
E --> G[Vue]
E --> H[Solid]
style B fill:#cba6f7,color:#11111b
style D fill:#89b4fa,color:#11111b

La forma interna de estas máquinas es reconocible aunque el núcleo de Zag sea propio y no dependa de XState. Estados con nombre, transiciones por evento, acciones nombradas que se resuelven en un solo sitio y efectos ligados al estado que los necesita.

// forma simplificada de la maquina de una capa desplegable
const maquina = createMachine({
  initialState: () => 'cerrado',
  states: {
    cerrado: {
      on: { ABRIR: { target: 'abierto', actions: ['invocarAlAbrir'] } },
    },
    abierto: {
      effects: ['atraparFoco', 'cerrarAlPulsarFuera', 'colocarCapaFlotante'],
      on: {
        CERRAR: { target: 'cerrado', actions: ['devolverFocoAlDisparador'] },
        SIGUIENTE: { actions: ['moverResaltado'] },            // transicion interna, no sale del estado
      },
    },
  },
})

La transición interna de SIGUIENTE es un detalle instructivo: mover el resaltado no cambia de estado, así que no debe reejecutar los efectos de entrada. Un modelo que no distinguiera transición interna de externa volvería a atrapar el foco en cada pulsación de flecha. Estas librerías se toman esa distinción muy en serio porque el coste de equivocarse es un componente que parpadea o que roba el foco, y ese es exactamente el tipo de defecto que nadie reproduce en desarrollo y todo el mundo sufre en producción.

💡
Cómo saber si tu componente pide una máquina de terceros

Cuenta las reglas de teclado, foco y capas que tu componente debe cumplir. Si son menos de cinco, escríbelas tú. Si pasan de quince —y cualquier selector con búsqueda, cualquier menú anidado y cualquier calendario las pasa— estás ante una especificación que otros ya transcribieron y verificaron. Reimplementarla no es un ejercicio de independencia, es aceptar mantener una copia peor de un documento público que ni siquiera escribiste.

Consumir, adaptar o escribir la tuya

Consumir una máquina ajena tiene una consecuencia que conviene aceptar con los ojos abiertos: el grafo no es tuyo. Puedes influir en él por los bordes —opciones de creación, propiedades reactivas, interceptar el cambio de estado, decidir el marcado— pero no puedes añadir un estado intermedio ni cambiar qué transición es legal. Cuando tu requisito no cabe en los bordes, no hay parche elegante posible, y esa rigidez es el precio directo de la garantía de conformidad.

Los bordes por los que sí puedes influir tienen una forma reconocible, y merece la pena verlos juntos porque delimitan con exactitud el espacio de personalización disponible.

const servicio = useMachine(menu.machine, {
  id: useId(),                                   // identidad estable entre servidor y cliente
  open: abierto,                                 // propiedad gobernada desde fuera
  onOpenChange: (d) => setAbierto(d.open),       // interceptas el resultado, no la transicion
  onSelect: (d) => ejecutarAccionDeDominio(d.value),
})

El identificador no es un detalle administrativo: de él se derivan todos los atributos que relacionan disparador y contenido, así que tiene que coincidir entre el marcado del servidor y el del cliente o la hidratación romperá silenciosamente las referencias accesibles. Y la pareja de propiedad gobernada más devolución de llamada es la única forma de compartir el control: delegas una variable del contexto hacia fuera, no el grafo. Puedes decidir si la capa está abierta, pero no puedes decidir que entre abierta y cerrada exista un tercer estado intermedio, y confundir ambas capacidades es la fuente habitual de frustración con estas librerías.

Necesidad Máquina de componente Máquina propia
Reglas de teclado y foco estándar resueltas y verificadas las escribes y las mantienes
Portabilidad entre frameworks por adaptador ninguna
Presentación y marcado totalmente libres totalmente libres
Estados nuevos en el grafo imposible sin bifurcar trivial
Lógica de tu dominio dentro fuera de sitio su lugar natural
Coste de mantenimiento de la comunidad tuyo

La última fila del cuadro esconde la decisión más importante y la que más equipos se saltan: no mezcles el dominio con el componente. La máquina del menú sabe abrir, resaltar y cerrar; no debe saber qué significa el elemento que has resaltado ni qué proceso de negocio dispara. Cuando aparece la tentación de meter una regla de tu aplicación dentro de la máquina del componente, la respuesta correcta casi siempre es tener dos máquinas y hacer que hablen: la del componente gobierna la interacción, la tuya gobierna el proceso, y la comunicación entre ambas se limita a eventos con nombre. Es el mismo reparto de propiedades que aplicaste al separar la cache de datos del grafo de flujo en el nivel dieciséis.

⚠️
Headless no significa sin coste

La promesa de que solo pagas el comportamiento y no la presentación es cierta y a veces se lee de más. Sigues adoptando una arquitectura completa: identificadores generados, propiedades que hay que repartir en el elemento correcto, un ciclo de vida propio por instancia y una convención de nombres que tu equipo debe aprender. Para un componente aislado eso es caro. La adopción de estas librerías se paga con volumen: rinde cuando construyes un sistema de diseño entero o cuando debes servir a varios frameworks, no cuando necesitas un solo desplegable en una sola aplicación.

El activo de una librería de componentes nunca fue el componente

Durante quince años la industria invirtió al revés la relación entre lo duradero y lo desechable: cuidaba muchísimo el aspecto, que caduca con cada cambio de moda y cada rediseño, y trataba el comportamiento como pegamento improvisado, cuando el comportamiento es lo único que no cambia nunca. Las reglas de un menú accesible son idénticas hoy a las de hace una década y serán idénticas dentro de otra, porque no derivan de la estética sino de cómo funcionan un teclado, un lector de pantalla y la atención humana. Zag es la consecuencia lógica de reconocerlo: si extraes ese núcleo invariante y lo escribes como máquina, obtienes un artefacto que sobrevive a los rediseños, a los cambios de framework y a la rotación del equipo, mientras que lo que se tira cada dos años —marcado y estilos— se queda donde debe estar, en tu aplicación y bajo tu control. Y aquí está la lección que trasciende a esta librería concreta y justifica su lugar en un nivel sobre alternativas: la máquina de estado no compite con tu framework, compite con el hábito de reimplementar protocolos. Cada vez que un equipo vuelve a escribir a mano el manejo de foco de un diálogo está reescribiendo, mal y sin saberlo, un grafo que ya estaba especificado en un documento público desde antes de que ese equipo existiera. Reconocer un protocolo ya escrito y ejecutarlo en vez de reinventarlo es, en el fondo, la misma destreza que este track lleva veintidós niveles enseñando, aplicada aquí al dominio donde más se repite el error.

⚔️ Disecciona una máquina de componente
  1. Toma un componente accesible que hayas escrito a mano y enumera por escrito todas sus reglas de teclado, foco y cierre. Cuenta cuántas son.
  2. Convierte esa lista en estados, eventos y transiciones antes de mirar ninguna librería. Marca cuáles de esas reglas tu implementación actual incumple.
  3. Monta el mismo componente con una máquina headless e inspecciona el objeto de propiedades que devuelve la proyección. Identifica cada atributo con la regla de la especificación que lo justifica.
  4. Localiza una transición interna que no deba reejecutar efectos de entrada y comprueba qué ocurre si la conviertes en externa.
  5. Busca en tu código una regla de dominio incrustada dentro de la lógica de un componente de interfaz y sepárala en una máquina propia que se comunique por eventos.
  6. Calcula el punto de equilibrio para tu equipo: cuántos componentes y cuántos frameworks hacen falta para que adoptar el patrón salga a cuenta frente a escribirlos tú.