wandres.dev
PATRÓN: WIZARDS · flujos de varios pasos

El wizard como máquina: cada paso un estado

Un asistente por pasos es el único dominio donde el modelo no hay que descubrirlo: producto ya lo entregó dibujado, con nodos que tienen nombre y flechas etiquetadas con botones. Esta lección examina por qué esa correspondencia lo convierte en el caso canónico del patrón, disecciona qué garantías destruye el índice numérico que casi todo el mundo usa en su lugar, separa el eje de la posición del eje de los datos acumulados y deriva del modelo la interfaz completa del asistente, indicador de progreso incluido, sin duplicar la verdad del grafo en ningún otro sitio.

⏱ 17 min

Casi todo el modelado empieza con un trabajo arqueológico: descubrir qué estructura escondía un código que creció añadiendo una bandera cada vez que apareció un caso nuevo. Con un asistente por pasos ese trabajo no existe. La estructura viene impresa en la especificación, dibujada en el prototipo y enunciada en voz alta por quien la pidió: primero los datos personales, luego la dirección, luego el pago, luego la revisión y el envío. El dominio llega ya en forma de grafo dirigido, con nodos que tienen nombre propio y aristas etiquetadas con acciones del usuario. Y aun así la implementación habitual traduce ese grafo a un entero que se incrementa, perdiendo en la traducción justo aquello que el grafo garantizaba. La pérdida no es cosmética: un entero admite operaciones que el flujo prohíbe y borra distinciones que el flujo necesita.

🎯 Al terminar esta lección sabrás
  • Reconocer la correspondencia entre paso y estado, y entre botón y evento con nombre.
  • Enumerar qué garantías destruye el índice numérico y cuáles recupera la enumeración de estados.
  • Separar el eje de la posición en el flujo del eje de los datos acumulados en context.
  • Derivar del modelo la interfaz completa del asistente sin duplicar su verdad en ningún sitio.

El grafo que ya venía dibujado

Para que un patrón sirva de caso canónico tiene que cumplir tres condiciones incómodas a la vez, y el asistente las cumple sin esfuerzo. La primera es que el conjunto de situaciones sea finito, pequeño y conocido de antemano: un alta tiene cinco pasos, no un número que dependa de los datos. La segunda es que las transiciones tengan causa externa y nombre natural: nadie avanza solo, se avanza porque alguien pulsó continuar. La tercera, la más rara, es que el diagrama resultante sea legible por personas que no programan. El statechart de un asistente es exactamente el mismo dibujo que diseño ya tenía en su documento, y esa coincidencia convierte el modelo en artefacto compartido en lugar de en jerga de ingeniería.

Hay una cuarta razón, menos evidente y más pedagógica: el asistente exhibe en un espacio diminuto casi todo el repertorio del track. Necesita guardas para impedir el avance con datos inválidos, necesita historia para volver sin perder lo escrito, necesita transiciones condicionales para las ramas que dependen de respuestas previas, necesita un actor invocado para el envío y estados finales con salida para devolver el resultado. Quien modela bien un asistente ha ejercitado el vocabulario completo sobre un dominio que entiende sin que nadie se lo explique.

🧭

Finito y conocido

El conjunto de pantallas se fija en tiempo de diseño y no depende de los datos. Eso permite enumerarlo, dibujarlo y recorrerlo entero en una prueba, tres cosas que dejan de ser posibles en cuanto un estado depende de un valor.

🔘

Transición con nombre natural

Nadie avanza por su cuenta: se avanza porque alguien pulsó continuar. El evento no hay que inventarlo ni deducirlo de un efecto secundario, ya existe en el vocabulario del producto y solo hay que escribirlo en mayúsculas.

🖼️

El dibujo ya existía

El statechart coincide con el diagrama que diseño hizo antes de escribir una línea. Esa coincidencia convierte el modelo en terreno común y permite discutir el flujo con quien lo pidió, sin traducirlo a jerga.

🧰

Ejercita el repertorio

Guardas, historia, transiciones condicionales, actores invocados y estados finales caben todos en un alta de cinco pantallas. Es el laboratorio más pequeño donde el vocabulario completo tiene sentido.

ℹ️
Canónico no significa trivial

Que el modelo sea evidente no quiere decir que el asistente sea fácil. La dificultad real de estos flujos nunca está en avanzar, está en todo lo demás: volver sin destruir, ramificar sin duplicar, validar sin bloquear, enviar sin cobrar dos veces y sobrevivir a que el usuario cierre la pestaña. Esas cuatro dificultades ocupan las cuatro lecciones siguientes, y todas se apoyan en que la primera esté bien resuelta.

El entero que sabe demasiada aritmética

La implementación por defecto guarda un número y lo incrementa. Un entero, sin embargo, es un elemento de una estructura algebraica con operaciones cerradas: se le puede sumar, restar y multiplicar, y el resultado sigue siendo un entero legítimo. Un flujo de pasos no tiene ninguna de esas propiedades. No existe la suma de dos pasos, no existe el paso menos uno y no existe el paso siete cuando solo hay cinco. Al elegir el entero como representación se importa toda esa aritmética a un dominio que no la admite, y el sistema de tipos deja de poder ayudar porque para él cualquier número es un valor válido.

Propiedad Índice numérico Estados con nombre
Tipo que lo describe number, con miles de millones de habitantes datos | direccion | pago | revision
Saltos ilegales sumar dos compila y se ejecuta la arista no existe, el salto no ocurre
Insertar un paso intermedio renumera todo y rompe condiciones en silencio añade un nodo y dos aristas
Ramas condicionales aritmética de índices con casos especiales aristas distintas desde el mismo nodo
Volcado de un informe de error la cifra tres no dice nada el nombre pago se explica solo
Prueba exhaustiva hay que inventar los recorridos el grafo los enumera

La fila de la inserción es la que más dinero cuesta. Un índice es una medida ordinal: codifica posición, que es una propiedad accidental, y no identidad, que es la esencial. Cuando negocio pide meter un paso de verificación entre la dirección y el pago, todas las condiciones que decían tres pasan a referirse a otra pantalla sin que nada falle en compilación ni en ejecución. Con nombres, insertar es una operación local y el resto del grafo ni se entera.

Pasos como estados, botones como eventos

La traducción es mecánica y por eso conviene hacerla despacio una vez. Cada pantalla es un estado. Cada botón es un evento con nombre de intención, no de destino. Y la tabla de transiciones concentra en un único lugar la respuesta a la pregunta de qué viene después de qué.

import { setup, assign } from 'xstate'

type Datos = { nombre: string; email: string; calle: string; ciudad: string; tarjeta: string }

export const alta = setup({
  types: {
    context: {} as { datos: Partial<Datos> },
    events: {} as
      | { type: 'SIGUIENTE'; parche: Partial<Datos> }
      | { type: 'ATRAS' }
      | { type: 'CONFIRMAR' },
  },
  actions: {
    acumular: assign({
      datos: ({ context, event }) =>
        event.type === 'SIGUIENTE' ? { ...context.datos, ...event.parche } : context.datos,
    }),
  },
}).createMachine({
  id: 'alta',
  initial: 'datos',
  context: { datos: {} },
  states: {
    datos: { on: { SIGUIENTE: { target: 'direccion', actions: 'acumular' } } },
    direccion: {
      on: { SIGUIENTE: { target: 'pago', actions: 'acumular' }, ATRAS: 'datos' },
    },
    pago: {
      on: { SIGUIENTE: { target: 'revision', actions: 'acumular' }, ATRAS: 'direccion' },
    },
    revision: { on: { CONFIRMAR: 'enviado', ATRAS: 'pago' } },
    enviado: { type: 'final' },
  },
})

Lo decisivo de este código no es lo que hace sino lo que deja de estar en otro sitio. El componente del paso de dirección ya no sabe que después viene el pago: emite SIGUIENTE y se desentiende. Esa inversión rompe el acoplamiento en cadena típico de los asistentes escritos a mano, donde cada pantalla importa el nombre de la siguiente y reordenar el flujo obliga a tocar todas. Aquí el orden vive en un solo archivo, y ese archivo es también el diagrama.

stateDiagram-v2
[*] --> datos
datos --> direccion: SIGUIENTE
direccion --> datos: ATRAS
direccion --> pago: SIGUIENTE
pago --> direccion: ATRAS
pago --> revision: SIGUIENTE
revision --> pago: ATRAS
revision --> enviado: CONFIRMAR
enviado --> [*]
💡
Nombra el evento por la intención, no por el destino

Llamar al evento IR_A_PAGO reintroduce el acoplamiento que acabas de eliminar: obliga a quien pulsa a saber adónde va y convierte cada rama condicional en un evento nuevo. SIGUIENTE describe lo que el usuario quiso —continuar— y deja que la máquina decida qué significa eso desde la posición actual. La misma regla explica por qué el botón de retroceso emite ATRAS y no IR_A_DIRECCION.

Posición, acumulación y todo lo derivado

Un asistente tiene dos ejes y confundirlos es el error de modelado más caro del patrón. El primero es la posición: finita, discreta, con nombres, y por eso vive en los estados. El segundo es la acumulación de lo que el usuario ha ido escribiendo: dominio inmenso, no enumerable, y por eso vive en context. La distinción no es una convención de la librería, es lo que mantiene el modelo analizable: si metieras el correo del usuario en la identidad del estado tendrías tantos estados como correos posibles y ninguna herramienta podría recorrerlos.

Con esa separación clara, casi todo lo que la interfaz necesita deja de guardarse y pasa a calcularse. El número de paso, el porcentaje de progreso, el texto del botón, si el retroceso está disponible: todo es una función pura de la posición actual y del contexto.

const ORDEN = ['datos', 'direccion', 'pago', 'revision'] as const

export function progreso(snapshot: Snapshot) {
  const actual = ORDEN.findIndex((paso) => snapshot.matches(paso))
  return {
    indice: actual + 1,
    total: ORDEN.length,
    porcentaje: actual < 0 ? 100 : Math.round((actual / ORDEN.length) * 100),
    puedeVolver: snapshot.can({ type: 'ATRAS' }),
  }
}

El número reaparece, pero invertido: ahora es una salida del modelo y no su fuente de verdad. Esa inversión es exactamente la diferencia entre un asistente que se puede reordenar en una tarde y uno que nadie se atreve a tocar. Y fíjate en la última línea: preguntarle a la máquina si admite un evento evita que el componente reimplemente la regla de cuándo se puede retroceder, que es la primera regla que se desincroniza cuando aparecen las ramas condicionales.

⚠️
No guardes lo que el grafo ya sabe

La tentación es añadir al contexto un campo con el paso actual o con el porcentaje, para no calcularlo. En cuanto ese campo existe hay dos fuentes de verdad y una de las dos se quedará atrás en alguna rama, normalmente la menos transitada. Un asistente con progreso almacenado acaba enseñando el ochenta por ciento en la primera pantalla el día que alguien añade un salto condicional y olvida actualizar el número.

El asistente es el dominio que se negó a esconder su forma

Que este patrón sea el caso canónico dice algo incómodo sobre todos los demás: no es que el asistente sea especialmente adecuado para una máquina de estados, es que es el único dominio de interfaz que nunca llegó a disfrazar la suya. Un carrito, un reproductor o una pantalla de autenticación tienen exactamente la misma naturaleza —posiciones discretas y transiciones nombradas— pero llegan al programador ya disueltas en banderas, porque nadie las dibujó antes de escribirlas. El asistente llega dibujado por accidente histórico: como el usuario ve los pasos, producto se ve obligado a enumerarlos, y esa enumeración forzada es el modelo. De ahí se sigue la lección que trasciende el patrón. Cuando escribes un asistente con un entero, no estás simplificando un modelo complejo: estás destruyendo deliberadamente un modelo que te habían regalado ya hecho, cambiando un grafo con garantías por una estructura algebraica que permite todo lo que el dominio prohíbe. Y la destrucción es silenciosa, porque el entero funciona el primer día y solo empieza a doler cuando aparece la tercera rama condicional o el segundo paso insertado a mitad. La disciplina que este nivel enseña, entonces, no es la de aprender a modelar asistentes, sino la de reconocer la forma del asistente en dominios que la esconden mejor: si puedes escribir la lista de pantallas y las flechas entre ellas en una servilleta, ya tienes el modelo, y cualquier representación que admita más estados que los de esa servilleta es una decisión de perder información, no de ganar simplicidad.

⚔️ Traduce tu asistente al grafo que ya era
  1. Toma un asistente real de tu producto y escribe en una lista sus pantallas con nombre; esa lista es tu conjunto de estados.
  2. Anota junto a cada pantalla los botones que la abandonan y renómbralos por intención, nunca por destino.
  3. Busca en el código actual el índice numérico y localiza cada sitio que hace aritmética con él; anota qué ocurriría si insertaras un paso en medio.
  4. Escribe la máquina con setup, con el parche de datos viajando en el evento y una única acción que acumule en context.
  5. Sustituye la barra de progreso por una función pura que derive el índice y el porcentaje de la posición actual.
  6. Comprueba con can que el botón de retroceso se deshabilita solo en el primer paso, sin que ningún componente conozca esa regla.