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

Enviar eventos desde la UI: send, payload y por qué la vista solo avisa

La otra mitad del puente es la salida: los handlers de la UI llaman a send con objetos de evento y la máquina decide qué transición ocurre. Esta lección cubre el disparo de eventos desde manejadores de React, los eventos con payload que transportan datos del formulario o del click, el uso de state.can para deshabilitar acciones que no llevarían a ninguna transición, y la idea central de inversión de control: la vista no manda, avisa. La UI reporta hechos y la máquina, dueña de sus reglas, resuelve, tanto si el hecho viene de un click como de un timeout o de una promesa.

⏱ 16 min

En la interfaz clásica, el handler de un botón hace cosas: valida, decide, muta estado, quizá dispara un efecto. Con una máquina, el handler hace una sola cosa —enviar un evento— y renuncia a decidir qué ocurre después. send({ type: "GUARDAR" }) no es una orden, es un aviso: “el usuario pulsó guardar”. Si la máquina está en un estado que acepta GUARDAR, transicionará; si no, el evento se ignora sin ruido. Esta renuncia deliberada de la vista a decidir es lo que convierte una UI en una proyección fiel de la máquina, y es también lo que la hace trivial de razonar: toda la lógica de qué-lleva-a-qué vive en un solo lugar, no repartida por cuarenta manejadores.

🎯 Al terminar esta lección sabrás
  • Escribir handlers de React que se limiten a llamar a send con un objeto de evento.
  • Transportar datos hacia la máquina mediante eventos con payload y recogerlos con assign.
  • Usar state.can para deshabilitar acciones que no producirían ninguna transición.
  • Interiorizar la inversión de control: la vista reporta hechos, la máquina decide transiciones.

El handler solo avisa

XState v5 exige que los eventos sean objetos con una propiedad type: se acabó el send("GUARDAR") de string suelto de v4, porque el objeto habilita el tipado estricto del payload. Un handler idiomático es una línea. No comprueba en qué estado está la máquina para decidir si puede guardar —eso es competencia de la máquina—; simplemente traduce el gesto del usuario a un evento y lo empuja al buzón del actor.

export function Editor() {
  const [state, send] = useMachine(maquinaFormulario);

  return (
    <form onSubmit={(e) => { e.preventDefault(); send({ type: "GUARDAR" }); }}>
      <button type="submit">Guardar</button>
      <button type="button" onClick={() => send({ type: "CANCELAR" })}>Cancelar</button>
    </form>
  );
}

Nombrar bien los eventos es media arquitectura. Un evento describe un hecho ocurrido —ENVIO_FORMULARIO, LLEGO_RESPUESTA, EXPIRO_SESION—, no una orden imperativa a la máquina —irAGuardando, mostrarError—. La diferencia no es cosmética: si el evento nombra el hecho, la misma señal puede provocar transiciones distintas según el estado, que es justo lo que un statechart hace bien y una maraña de if hace mal. Un CLIC_GUARDAR desde editando guarda; desde guardando no hace nada; desde fallo reintenta. Un único evento, tres respuestas, todas decididas por la máquina y ninguna por la vista.

ℹ️
Un evento no aceptado no es un error: es un no-op

Si la máquina está en guardando y llega otro GUARDAR, no pasa nada: el estado no declara transición para ese evento, así que se descarta en silencio. Esto elimina toda una familia de bugs de doble envío sin un solo if en la vista. El botón puede dispararse dos veces y la máquina, no la UI, garantiza que solo la primera cuente. La vista queda libre de defender esa invariante.

Nótese la dirección del flujo: el gesto entra como evento, la máquina transiciona, el nuevo snapshot sale hacia la vista y la vista se repinta. Nunca al revés. Ese lazo cerrado y unidireccional —la vista avisa, la máquina decide, la vista refleja— es el mismo que impondrá Redux más adelante en el track, pero aquí lo obtienes de balde por el simple hecho de usar una máquina en lugar de un puñado de manejadores sueltos.

Eventos con payload

Los eventos rara vez son señales vacías; suelen llevar datos: el texto de un input, el id de un elemento, las coordenadas de un click. El payload viaja como propiedades adicionales del objeto de evento, y la máquina lo recoge en assign mediante ({ event }) => event.valor. El tipado que declaraste en setup({ types: { events } }) obliga a que el payload enviado y el leído coincidan, de modo que un evento mal formado es un error de compilación, no de ejecución.

// La vista envía el dato como parte del evento
<input
  value={state.context.texto}
  onChange={(e) => send({ type: "ESCRIBIR", valor: e.target.value })}
/>
// La maquina declara el evento con payload y lo recoge con assign
events: {} as { type: "ESCRIBIR"; valor: string } | { type: "GUARDAR" },
// ...
editando: {
  on: {
    ESCRIBIR: { actions: assign({ texto: ({ event }) => event.valor }) },
    GUARDAR: "guardando",
  },
},

Este patrón hace del input un componente controlado por la máquina: su value sale de state.context.texto y su onChange reporta el cambio como evento. La máquina es la única fuente de verdad del texto; la vista lo muestra y avisa de las ediciones, pero no lo custodia. Una disciplina útil sobre el payload: transporta el dato mínimo que la máquina no puede deducir por sí sola —el texto tecleado, el id pulsado, el fichero soltado— y nada más. No metas en el evento estado que la máquina ya guarda en su contexto ni copies en él valores derivados, porque eso reintroduce la duplicación y la desincronización que la máquina venía a eliminar.

💡
El payload tipado convierte errores de datos en errores de compilación

Al declarar cada evento en setup({ types: { events } }) con la forma exacta de su payload, TypeScript verifica en ambos extremos: el send desde la vista debe mandar las propiedades correctas, y el assign de la máquina las recibe ya tipadas. Un evento ESCRIBIR sin su valor, o con un número donde se esperaba texto, no compila. La frontera entre vista y máquina queda cubierta por el compilador, que es justo donde más fácil resulta equivocarse cuando los datos cruzan de un lado a otro.

can: preguntar antes de ofrecer

A veces la vista sí necesita saber si una acción tiene sentido ahora —para deshabilitar un botón, atenuar un menú— sin duplicar las reglas de la máquina. Para eso está state.can({ type: "GUARDAR" }): devuelve true si, en el estado actual, ese evento produciría alguna transición o acción. La vista pregunta, no calcula; la respuesta la da la máquina a partir de sus propias reglas, así que la habilitación del botón nunca se desincroniza de lo que la máquina realmente acepta.

<button
  type="submit"
  disabled={!state.can({ type: "GUARDAR" })}
>
  Guardar
</button>

state.can es, en el fondo, una simulación pura: la máquina calcula qué ocurriría con ese evento en el snapshot actual sin ejecutar efectos ni mutar nada, y devuelve si habría cambio. Por eso llamarlo en cada render es barato, y por eso su respuesta concuerda siempre con lo que sucederá de verdad al enviar el evento: es la misma máquina contestando dos veces la misma pregunta, una para pintar el control y otra para transicionar.

⚠️
can deshabilita, pero no sustituye a la máquina

Usar state.can para atenuar un botón mejora la experiencia, pero nunca es la barrera de seguridad. La barrera real es que la máquina ignore el evento si no lo acepta. Deshabilitar en la vista y no declarar la transición en la máquina serían dos fuentes de verdad; deshabilitar en la vista preguntándole a la máquina con can mantiene una sola. Si algún día quitas el disabled, la máquina sigue protegida.

No solo el usuario avisa: eventos del sistema

send desde un handler es solo una de las fuentes de eventos, y verlo así completa la imagen. Cuando la máquina invoca un actor con invoke, su resolución llega como los eventos internos onDone u onError; un bloque after convierte el paso del tiempo en un evento de expiración; un actor hijo notifica al padre con sendParent. Para la máquina todos son iguales: hechos que entran por el buzón y que ella interpreta según el estado en el que se encuentra.

flowchart LR
U[clic del usuario send] --> B[buzon del actor]
P[promesa onDone onError] --> B
T[timeout after] --> B
H[actor hijo sendParent] --> B
B --> D[la maquina decide segun el estado]
cargando: {
  invoke: {
    src: "pedirResultados",
    onDone: "listo",    // evento interno: la promesa se resolvio
    onError: "fallo",   // evento interno: la promesa fallo
  },
  after: { 8000: "fallo" },   // evento de tiempo: expiro el plazo
},

Que un timeout y un click compartan el mismo mecanismo —un evento que la máquina resuelve— es lo que mantiene uniforme la lógica de la UI. No hay un camino para las acciones del usuario y otro para los efectos del sistema: hay un solo buzón y un solo conjunto de reglas, y por eso la traza de una sesión es una simple lista de eventos que puedes registrar, reproducir y usar como test.

📝
Eventos serializables: repetición y time-travel

Como un evento es un objeto plano con un type y su payload, es serializable: puedes registrarlo, mandarlo por la red o guardarlo en disco. Una lista de eventos más el estado inicial reconstruye cualquier situación reproduciéndolos en orden, el mismo principio del event sourcing. De ahí salen el time-travel debugging, los tests que son solo secuencias de eventos y la telemetría que reproduce en tu máquina el fallo exacto de un usuario. Que la vista solo emita eventos, y nunca mute estado, es lo que hace todo esto posible.

📣

send: el hecho

La vista traduce un gesto en un evento con type y, si hace falta, payload. Nunca decide la transición; solo reporta que algo ocurrió.

🚦

can: la pregunta

state.can deja que la vista consulte si un evento tendría efecto, para atenuar controles sin duplicar las reglas de la máquina.

⏱️

invoke y after: el sistema

Promesas y temporizadores entran por el mismo buzón que los clicks. La máquina no distingue el origen: solo el hecho y el estado.

La UI propone, la máquina dispone: inversión de control como arquitectura

El giro conceptual del nivel es una inversión de control tan silenciosa que es fácil no notarla. En la UI imperativa, el manejador es el cerebro: decide, valida, muta, orquesta efectos, y el estado es su resultado secundario. Con una máquina, el cerebro se ha mudado fuera de la vista, y el manejador queda reducido a un sensor: detecta un gesto del usuario y lo traduce a un hecho —ESCRIBIR, GUARDAR, CANCELAR— que empuja a la máquina sin pretender saber qué provocará. Esta asimetría —la vista propone hechos, la máquina dispone transiciones— tiene consecuencias que trascienden lo estético. Primera: toda la lógica de negocio vive en un artefacto puro, testeable sin renderizar y portable entre frameworks, mientras la vista se vuelve delgada y casi tonta. Segunda: los bugs de concurrencia de la UI —doble submit, clicks durante una carga, eventos fuera de orden— dejan de resolverse con banderas defensivas en cada handler y pasan a ser propiedades del statechart, decididas una vez y para todas. Tercera: razonar sobre la app se vuelve local; para saber qué puede pasar al pulsar guardar, no lees cuarenta manejadores repartidos, lees el estado editando de la máquina. Y como los efectos del sistema entran por el mismo buzón que los gestos, no hay dos regímenes que reconciliar: una descarga que termina y un botón que se pulsa son el mismo tipo de cosa. Cuando un handler tuyo hace más que un send, ha vuelto a acumular poder de decisión, y ese poder pertenece a la máquina. La regla es exigente y liberadora a la vez: la vista solo avisa.

⚔️ Adelgaza tus manejadores
  1. Toma un handler que hoy valide y decida, y redúcelo a un único send con un evento que nombre el hecho, no la acción.
  2. Convierte un input no controlado en uno controlado por la máquina: value desde state.context y onChange que envía ESCRIBIR con payload.
  3. Declara el evento con payload en setup({ types }) y recógelo con assign; provoca un error de tipo enviando un payload equivocado.
  4. Añade disabled={!state.can(...)} a un botón y verifica que se sincroniza solo al cambiar de estado, sin lógica extra en la vista.
  5. Dispara GUARDAR dos veces seguidas y comprueba que la máquina, no un flag de la vista, evita el doble envío.
  6. Añade un after con un timeout al estado cargando y observa que el evento de tiempo entra por el mismo buzón que un click, sin tocar la vista.