wandres.dev
MÁQUINAS EN LA UI · React, Vue, Solid

Vue y Solid: la máquina como lógica agnóstica del framework

La misma máquina que conectamos a React funciona sin cambios en Vue y en Solid. Esta lección muestra los adaptadores equivalentes —@xstate/vue con useMachine y useSelector devolviendo refs reactivas, y @xstate/solid proyectando el snapshot en un store de grano fino— y extrae la lección de arquitectura: la máquina es lógica de actor pura y portable, y cada adaptador solo tiende un puente entre el actor y el modelo de reactividad de su framework. El patrón snapshot más send es universal; hasta puedes ejecutar la máquina sin framework alguno con createActor, que es lo que hace tan barato testearla.

⏱ 17 min

Hemos escrito bastante código de React en este nivel, pero apenas hemos tocado la máquina: setup, createMachine, estados, eventos y assign no mencionan React por ninguna parte. Esa ausencia no es casual, es el activo. La máquina es lógica de actor pura, y un actor ofrece siempre la misma interfaz —un snapshot que observar y un buzón al que enviar— sin importar quién la observe. Por eso la misma máquina de búsqueda de las lecciones anteriores se monta en Vue o en Solid cambiando el import del adaptador y nada más de la lógica. Lo único que cada framework aporta es su forma de conectar la observación del snapshot con su sistema de reactividad. Ver eso con claridad reordena las prioridades de aprendizaje: invierte en la máquina, que es portable; el adaptador es un detalle que se aprende en una tarde.

🎯 Al terminar esta lección sabrás
  • Reconocer que la máquina no cambia entre frameworks: solo cambia el adaptador que la observa.
  • Montar la máquina en Vue con @xstate/vue, donde el snapshot llega como una ref reactiva.
  • Montar la máquina en Solid con @xstate/solid, donde el snapshot se proyecta en un store de grano fino.
  • Ejecutar la máquina sin framework con createActor y ver el patrón universal snapshot más send.

La misma máquina, tres adaptadores

Los tres adaptadores oficiales —@xstate/react, @xstate/vue y @xstate/solid— exponen la misma familia de hooks o composables: useMachine, useActor, useActorRef y useSelector. Los nombres coinciden a propósito, porque el problema que resuelven es idéntico: instanciar un actor, atarlo al ciclo de vida del componente, observar su snapshot y ofrecer un send. Lo que difiere es el envoltorio reactivo del snapshot, y ese envoltorio lo dicta el framework, no XState.

flowchart TD
M[maquina logica de actor pura] --> RA[xstate react]
M --> VU[xstate vue]
M --> SO[xstate solid]
RA --> RR[snapshot y send con re-render]
VU --> VR[snapshot como ref reactiva y send]
SO --> SR[snapshot como store de grano fino y send]

La forma exacta de la devolución cambia un poco entre adaptadores —React y Solid devuelven una tupla [snapshot, send]; Vue devuelve un objeto { snapshot, send, actorRef }—, pero esa diferencia es superficial y responde a la ergonomía de cada ecosistema. La sustancia es idéntica en los tres: un snapshot inmutable que describe la situación y una función send que empuja hechos al buzón.

📝
Más allá de los tres oficiales

El patrón no se detiene en React, Vue y Solid. La comunidad mantiene adaptadores para Svelte —donde el actor encaja de forma natural en un store con la interfaz subscribe— y para Angular, donde el snapshot se expone como un Observable que el pipe async consume. Todos hacen lo mismo con distinta piel: envolver la observación del actor en el primitivo reactivo idiomático del framework. Que un actor implemente el contrato subscribe estándar es justo lo que hace triviales esos puentes, presentes y futuros.

Vue: el snapshot como ref reactiva

En Vue, useMachine devuelve un objeto { snapshot, send, actorRef }, y snapshot es una ref. Dentro de script setup accedes al valor con snapshot.value; en la plantilla, Vue desenvuelve la ref automáticamente, así que escribes snapshot.matches("cargando") sin .value. La reactividad de Vue se encarga de que la plantilla se recompute cuando el snapshot cambia, del mismo modo que lo haría con cualquier otra ref.

<script setup lang="ts">
import { useMachine } from "@xstate/vue";
import { maquinaBusqueda } from "./maquina";

const { snapshot, send } = useMachine(maquinaBusqueda);
</script>

<template>
  <button v-if="snapshot.matches('inactivo')" @click="send({ type: 'BUSCAR' })">Buscar</button>
  <Spinner v-else-if="snapshot.matches('cargando')" />
  <ul v-else-if="snapshot.matches('listo')">
    <li v-for="r in snapshot.context.resultados" :key="r">{{ r }}</li>
  </ul>
</template>

Para el grano fino, @xstate/vue ofrece useSelector(actorRef, selector), que devuelve una ref derivada que solo cambia cuando cambia la porción seleccionada, con la misma semántica que su gemelo de React. El patrón es reconocible de inmediato: snapshot.matches, snapshot.context y send son idénticos a los de React; solo el envoltorio ref y la sintaxis de plantilla delatan que estás en Vue. Quien haya escrito la vista de React de las lecciones anteriores no aprende un modelo nuevo, sino una traducción de sintaxis: los conceptos —snapshot, matches, send— viajan intactos.

Solid: el snapshot como store de grano fino

Solid no tiene renders que rehacer: su reactividad es de grano fino y solo re-ejecuta las expresiones concretas que leen un valor cambiado. Por eso @xstate/solid no envuelve el snapshot en una ref sino que lo proyecta en un store de Solid. useMachine devuelve una tupla [snapshot, send] como en React, pero acceder a snapshot.context.resultados en el JSX crea una dependencia fina: solo esa lectura se recomputa cuando ese dato cambia, sin re-renderizar el componente entero.

import { useMachine } from "@xstate/solid";
import { Show, For } from "solid-js";
import { maquinaBusqueda } from "./maquina";

export function Buscador() {
  const [snapshot, send] = useMachine(maquinaBusqueda);

  return (
    <>
      <Show when={snapshot.matches("inactivo")}>
        <button onClick={() => send({ type: "BUSCAR" })}>Buscar</button>
      </Show>
      <Show when={snapshot.matches("listo")}>
        <ul><For each={snapshot.context.resultados}>{(r) => <li>{r}</li>}</For></ul>
      </Show>
    </>
  );
}
ℹ️
El grano fino de Solid hace a useSelector casi innecesario

En React, useSelector existe porque el modelo de render es de grano grueso: sin él, todo el componente se rehace ante cualquier snapshot. En Solid, el snapshot ya es un store reactivo fino, así que leer snapshot.context.contador en el JSX suscribe solo a ese campo por construcción. useSelector sigue disponible para derivar valores calculados, pero la optimización que en React es obligatoria, en Solid viene de serie por el modelo del framework.

Esta diferencia ilustra el punto central del nivel mejor que ninguna otra: la misma máquina, sin cambiar una línea, obtiene grano grueso en React y grano fino en Solid. La granularidad de reactividad no es una propiedad de la máquina, sino del adaptador y del framework que hay debajo. La máquina solo emite snapshots; qué se hace con esa emisión —re-renderizar todo, recomputar una plantilla, actualizar un nodo del DOM— es decisión del observador.

Un detalle de montaje vale para los tres adaptadores: define la máquina en el ámbito del módulo, no dentro del componente. Crearla en cada render la reconstruiría una y otra vez y podría reiniciar el actor; el adaptador espera una lógica estable a la que dar identidad.

⚠️
Pasa datos iniciales por input, no cierres sobre props

Cuando la máquina necesita un valor inicial que viene de props —un id, un usuario—, no la definas dentro del componente para capturarlo por cierre. Pásalo como input al arrancar el actor: los tres adaptadores aceptan una opción input en useMachine, y la máquina lo lee en su context inicial. Así la lógica sigue viviendo fuera, estable y testeable, y el dato entra por un canal explícito en vez de por una clausura que ata la máquina a un componente concreto.

El núcleo agnóstico: ejecutar la máquina sin framework

La prueba definitiva de que la máquina no depende de ningún framework es que puedes arrancarla con createActor —la API base de XState— y manejarla desde JavaScript plano: te suscribes a sus snapshots, envías eventos y lees el contexto sin montar un solo componente. Esto es exactamente lo que hacen los adaptadores por dentro, y es también la forma en que testeas la lógica: un test recorre la máquina enviando eventos y comprobando estados, sin render ni DOM.

import { createActor } from "xstate";
import { maquinaBusqueda } from "./maquina";

const actor = createActor(maquinaBusqueda);
actor.subscribe((snapshot) => console.log(snapshot.value));
actor.start();                 // imprime: inactivo
actor.send({ type: "BUSCAR" }); // imprime: cargando
// un test asertaria: expect(actor.getSnapshot().matches("cargando")).toBe(true)

Esta portabilidad tiene un corolario organizativo: un equipo puede escribir y versionar las máquinas en un paquete compartido, y que las apps —una en React, otra en Solid, un widget embebido en JavaScript plano— las consuman sin reimplementar la lógica. La regla de negocio deja de duplicarse por framework y pasa a tener un único dueño, con un único juego de tests.

💡
Del test manual al testing basado en modelos

Como la máquina es pura y recorrerla es solo enviar eventos, puedes ir más allá del test a mano: las herramientas de model-based testing de XState recorren automáticamente todos los caminos del statechart y generan casos que cubren cada transición y cada estado. El mismo artefacto que ejecuta tu UI se convierte en la especificación desde la que se derivan las pruebas. Ninguna capa de vista permite algo así, porque en ella la lógica está enredada con el render.

⚛️

React devuelve tupla

const [state, send] = useMachine(m). Re-render por transición; useSelector para acotar la suscripción a una rebanada.

🟢

Vue devuelve objeto

const { snapshot, send } = useMachine(m). El snapshot es una ref; el grafo de dependencias de Vue recomputa la plantilla.

🔷

Solid devuelve store

const [snapshot, send] = useMachine(m). El snapshot es un store fino: cada lectura en el JSX se suscribe por separado.

La máquina es el activo portable; el adaptador es una mercancía

El error estratégico al elegir tecnología es enamorarse del framework y tratar la lógica como su apéndice. Este nivel enseña lo contrario, y Vue y Solid lo demuestran de forma tangible: la máquina —qué estados existen, qué transiciones son válidas, qué efectos se invocan, cómo muta el contexto— es lógica de actor pura que no importa React, Vue ni Solid, y que por tanto sobrevive a la migración entre ellos y al paso del tiempo mucho mejor que cualquier capa de vista. Los adaptadores son mercancías intercambiables: cada uno resuelve el mismo problema —observar un snapshot y ofrecer un send— acomodándolo al modelo de reactividad de su framework, que es lo único que de verdad distingue a React de Vue de Solid. React re-renderiza el componente y necesita useSelector para acotar; Vue envuelve el snapshot en una ref y deja que su grafo de dependencias haga el trabajo; Solid lo proyecta en un store fino y suscribe a cada lectura por separado. Tres soluciones al mismo problema, y la máquina no se entera de cuál la observa; de hecho, con createActor no la observa ninguna y la máquina funciona igual. La consecuencia práctica es una guía de inversión: el tiempo que dedicas a modelar bien el statechart rinde en cualquier framework y en el siguiente que llegue; el tiempo que dedicas a dominar las particularidades de un adaptador rinde solo mientras uses ese framework. Cuando conectas la máquina a un framework, no estás acoplando tu lógica a él; estás enchufando una lógica que existía antes y existirá después a un observador que resulta ser el de hoy. Esa independencia es, al final, la mayor promesa de modelar el estado como máquinas.

⚔️ Migra sin tocar la lógica
  1. Toma la maquinaBusqueda de las lecciones anteriores y móntala en Vue con @xstate/vue sin modificar una sola línea de la máquina.
  2. Móntala también en Solid con @xstate/solid y compara cómo Show y For reemplazan a los condicionales de JSX de React.
  3. Señala en cada adaptador dónde aparece el snapshot y dónde el send, y confirma que el patrón es el mismo en los tres.
  4. Explica por qué useSelector es imprescindible en React, útil en Vue y casi accesorio en Solid, en términos del modelo de reactividad de cada uno.
  5. Escribe un test de la máquina con createActor y send, sin montar ningún framework, y comprueba que cubre la lógica que las tres vistas comparten.
  6. Toma un componente que ya tengas con estado en useState y reescribe solo su lógica como máquina; observa que la vista queda casi idéntica en cualquiera de los tres frameworks.