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.
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.
- 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.
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.
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.
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.
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.
- 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.
- 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.
- 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.
- Localiza una transición interna que no deba reejecutar efectos de entrada y comprueba qué ocurre si la conviertes en externa.
- 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.
- 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ú.