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

Renderizar según el estado: matches y estados anidados

Con la máquina conectada, la vista se vuelve una función pura del snapshot. Esta lección enseña a decidir qué pintar a partir de state.value en lugar de una maraña de banderas booleanas paralelas: state.matches para estados simples, matches con objeto para estados compuestos, anidados y paralelos, y hasTag para agrupar estados sin acoplar la vista a la topología interna de la máquina. El resultado es una UI en la que los estados imposibles dejan de ser representables porque la máquina, no el componente, decide qué situaciones existen.

⏱ 17 min

Antes de las máquinas, un componente de carga solía arrastrar tres booleanos —cargando, error, hayDatos— y una batería de condicionales para cubrir sus combinaciones. El problema es que ocho combinaciones existen en el tipo pero solo cuatro tienen sentido: cargando y error a la vez es un estado imposible que el código, sin embargo, puede representar y renderizar. Una máquina elimina esa clase de bug de raíz, porque solo hay un state.value a la vez y describe una situación legítima por construcción. La vista, entonces, se reduce a una pregunta: dado este snapshot, ¿qué pinto? Y la respuesta se lee, no se calcula.

🎯 Al terminar esta lección sabrás
  • Sustituir las banderas booleanas paralelas por un único state.value como fuente de verdad de la vista.
  • Usar state.matches("cargando") para render condicional sobre estados simples.
  • Manejar estados compuestos, anidados y paralelos con state.matches de objeto.
  • Agrupar estados con state.hasTag sin acoplar la vista a la topología interna.

La vista como función del estado

Con una máquina, el render deja de ser una tabla de verdad de flags y pasa a ser una lectura directa. state.matches("cargando") devuelve true si el estado actual es exactamente ese; encadenas esas preguntas y cada rama de la UI corresponde a un estado declarado. No hay combinaciones espurias porque el actor solo puede estar en un estado a la vez. Fíjate en que el componente ni siquiera necesita conocer las transiciones: solo pregunta dónde está la máquina y pinta en consecuencia.

import { useMachine } from "@xstate/react";
import { maquinaBusqueda } from "./maquina";

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

  if (state.matches("inactivo")) return <button onClick={() => send({ type: "BUSCAR" })}>Buscar</button>;
  if (state.matches("cargando")) return <Spinner />;
  if (state.matches("fallo")) return <ErrorBox onRetry={() => send({ type: "REINTENTAR" })} />;
  return <Resultados items={state.context.resultados} />;
}

Como el state.value proviene de un tipo unión —la máquina enumera sus estados— el compilador conoce el conjunto exacto de valores posibles, y una cadena de matches que los cubra todos es, en la práctica, un pattern matching exhaustivo: si añades un estado a la máquina y olvidas su rama, el hueco es visible en un solo lugar, no disperso por la app. La vista se vuelve tan honesta como la máquina que proyecta.

💡
matches no es igualdad de string, es contención jerárquica

state.matches("cargando") no compara state.value por igualdad literal. Comprueba si el estado actual está dentro de cargando, lo que incluye cualquier subestado anidado. Si cargando tiene hijos y la máquina está en cargando.validando, state.matches("cargando") sigue devolviendo true. Esa semántica de contención es la que hace que la vista pueda razonar a la granularidad que quiera sin romperse cuando la máquina gana subestados por dentro.

Estados anidados y matches jerárquico

Los statecharts existen precisamente para descomponer estados en subestados. Un estado cargando puede tener una fase pidiendo y otra validando; un formulario editando puede anidar limpio y sucio. Para leer un subestado usas state.matches con un objeto que refleja la jerarquía: state.matches({ cargando: "validando" }). La vista elige su nivel de detalle: pregunta por el padre para lo grueso y por el hijo para lo fino, y ambas lecturas conviven.

stateDiagram-v2
[*] --> inactivo
inactivo --> cargando : BUSCAR
state cargando {
  [*] --> pidiendo
  pidiendo --> validando : RESPUESTA
}
cargando --> listo : DONE
cargando --> fallo : ERROR
fallo --> cargando : REINTENTAR
listo --> [*]
// Lectura gruesa y fina del mismo snapshot conviviendo sin conflicto
const enCarga = state.matches("cargando");                   // padre: true en pidiendo y validando
const validando = state.matches({ cargando: "validando" });  // hijo concreto

return (
  <div aria-busy={enCarga}>
    {validando ? <p>Verificando resultados...</p> : null}
    {enCarga ? <BarraProgreso /> : null}
  </div>
);

La anidación no es adorno: el subestado hereda las transiciones del padre, así que un evento CANCELAR declarado en cargando funciona igual en pidiendo y en validando sin repetirlo. La vista, al preguntar por el padre, se beneficia de esa herencia sin saber que existe.

Estados paralelos: dos dimensiones a la vez

A veces un componente vive en dos dimensiones independientes al mismo tiempo: un reproductor está reproduciendo o pausado y, a la vez, conSonido o silenciado. Modelar eso con estados planos daría un producto cartesiano de cuatro estados que crece sin control. Un estado parallel declara regiones ortogonales, y la máquina está en un subestado de cada región simultáneamente. La vista lee cada región por separado con state.matches de objeto.

// La maquina: dos regiones ortogonales activas a la vez
reproductor: {
  type: "parallel",
  states: {
    reproduccion: {
      initial: "pausado",
      states: { pausado: { on: { PLAY: "reproduciendo" } }, reproduciendo: { on: { PAUSA: "pausado" } } },
    },
    sonido: {
      initial: "conSonido",
      states: { conSonido: { on: { SILENCIAR: "silenciado" } }, silenciado: { on: { ACTIVAR: "conSonido" } } },
    },
  },
},
// La vista lee cada dimension de forma independiente
const reproduciendo = state.matches({ reproductor: { reproduccion: "reproduciendo" } });
const silenciado = state.matches({ reproductor: { sonido: "silenciado" } });

Las regiones no se estorban: silenciar no altera si estás reproduciendo. Cada dimensión evoluciona con sus propios eventos, y la explosión combinatoria que temías con flags queda descrita de forma compacta y sin estados imposibles.

Tags: agrupar estados sin acoplar la vista

A veces varios estados comparten una misma pinta desde la UI —pidiendo, validando y un futuro reintentando son, para el usuario, “está ocupado”—. Enumerarlos todos con matches acopla la vista a la topología interna: cada estado nuevo obliga a tocar el componente. Los tags resuelven esto. Etiquetas los estados en la máquina y la vista pregunta por la etiqueta con state.hasTag("ocupado"), ajena a cuántos ni cuáles estados la llevan.

// En la maquina: cada estado declara sus tags
cargando: { tags: ["ocupado"], /* ... */ },
reintentando: { tags: ["ocupado"], /* ... */ },

// En la vista: una sola pregunta estable frente a cambios internos
{state.hasTag("ocupado") && <Spinner />}

Un tag es una capa de indirección deliberada: nombra una intención de presentación —ocupado, editable, erróneo— y deja que la máquina decida qué estados la cumplen. Cuando refactorizas el statechart y partes un estado en tres, la vista no se entera si los tres siguen llevando el tag. Es el mismo desacoplamiento que un selector aporta sobre el contexto, aplicado ahora al estado finito.

🎯

matches para lo concreto

Cuando la rama de UI corresponde a un estado o subestado específico, state.matches es la lectura directa y con tipos estrictos del state.value.

🏷️

hasTag para lo transversal

Cuando varios estados comparten apariencia, un tag agrupa sin nombrar. La vista sobrevive a que añadas estados nuevos con el mismo tag.

🧩

Contexto para el detalle

El estado finito decide qué componente; state.context alimenta sus datos. Separar ambos evita meter datos en el nombre del estado.

📝
El estado decide la forma; el contexto, los datos

Una tentación frecuente es codificar datos en el nombre del estado —cargando_pagina_3, error_404—, y así multiplicar estados hasta el infinito. La disciplina correcta separa dos cosas: el estado finito describe la forma de la situación —cargando, listo, fallo— y el context guarda los datos variables —qué página, qué código de error—. La vista usa matches para elegir qué pintar y context para rellenarlo. Mantener finita la cardinalidad de estados es lo que conserva legible el statechart y exhaustiva la comprobación de la vista.

matches convierte los estados imposibles en no representables

La vieja sopa de booleanos no era solo fea: era incorrecta por diseño. Con cargando, error y hayDatos como flags independientes, el tipo del componente admite dos elevado a tres situaciones, pero solo un puñado son coherentes; el resto son estados imposibles que el compilador acepta y que tarde o temprano alguien renderiza —el spinner que se queda girando sobre un error, la lista que aparece mientras el error dice que falló—. Modelar con una máquina invierte la carga de la prueba: en lugar de que el desarrollador recuerde qué combinaciones evitar, la máquina declara qué estados existen, y state.value solo puede ser uno de ellos a la vez. La vista, al leer con matches, no puede pintar una combinación ilegítima porque esa combinación no es un valor posible del snapshot. Los estados paralelos refinan la idea: cuando dos dimensiones son de verdad independientes, las declaras ortogonales y sigues sin poder representar lo imposible dentro de cada una. Los tags y el matches jerárquico añaden una segunda propiedad valiosa: desacoplan el nivel de detalle de la vista del nivel de detalle de la máquina, de modo que refinar el statechart por dentro —partir cargando en fases— no rompe una vista que preguntaba por el padre o por un tag. Has dejado de defender invariantes con condicionales y has empezado a hacerlos indecibles. Esa es la promesa entera de las máquinas en la UI, y matches es su herramienta cotidiana.

⚔️ Haz imposibles los imposibles
  1. Reescribe un componente que uses hoy con tres booleanos como una máquina de cuatro estados y renderiza con state.matches.
  2. Enumera las combinaciones de booleanos que el tipo permitía pero que nunca debían ocurrir, y confirma que ninguna es un state.value válido.
  3. Parte cargando en subestados pidiendo y validando; comprueba que state.matches("cargando") sigue verdadero en ambos.
  4. Modela dos dimensiones independientes con un estado parallel y léelas por separado con matches de objeto.
  5. Añade un tag ocupado a los estados de espera y cambia la vista a state.hasTag; agrega luego un estado nuevo con ese tag sin tocar el componente.
  6. Intenta representar deliberadamente un estado imposible desde la vista y observa que no tienes forma de nombrarlo con el snapshot.