Ramas condicionales: el flujo que se adapta al usuario
Un asistente real casi nunca es una fila: hay pasos que solo aparecen si la respuesta anterior los reclama, tramos que se saltan enteros y caminos que vuelven a juntarse. Esta lección contrapone las dos formas de expresar esa bifurcación —el array de transiciones guardadas en el origen y el estado enrutador sin evento—, explica el orden exacto en que se resuelven acciones y guardas, ataca el problema que nadie modela, que es la respuesta cambiada a posteriori invalidando una rama ya recorrida, y muestra qué hacer con el indicador de progreso cuando el total deja de ser constante.
La fila recta del primer ejemplo dura hasta la primera reunión con negocio. Entonces aparece la frase que reorganiza el flujo entero: si el cliente es una empresa hay que pedirle los datos fiscales, si elige recogida en tienda no hace falta la dirección de envío, si el importe supera cierto umbral hay que verificar la identidad. Cada una de esas frases es una bifurcación, y la forma de escribirlas decide si el asistente sigue siendo legible al quinto condicional o se convierte en el archivo que nadie abre. Lo tentador es resolverlo en el sitio donde duele, poniendo una condición en el manejador del botón que calcula el siguiente paso. Lo correcto es reconocer que acaba de aparecer una decisión con nombre propio en el dominio, y que una decisión merece existir en el grafo con la misma dignidad que un paso.
- Expresar la bifurcación con un array de transiciones guardadas cuya última rama actúa de red.
- Centralizar decisiones compartidas en un estado enrutador resuelto sin evento en el mismo microstep.
- Ordenar correctamente la acumulación de la respuesta y la evaluación de la guarda que la usa.
- Podar del contexto la rama abandonada cuando el usuario cambia una respuesta ya dada.
La bifurcación en el origen
La forma más directa es la que ya conoces de la validación: un array de transiciones para el mismo evento, evaluado en orden, donde la primera guarda satisfecha gana y la última entrada sin guarda recoge el caso restante.
tipoCliente: {
on: {
SIGUIENTE: [
{ target: 'datosFiscales', guard: 'esEmpresa', actions: 'acumular' },
{ target: 'direccion', actions: 'acumular' },
],
},
},
datosFiscales: {
on: { SIGUIENTE: { target: 'direccion', actions: 'acumular' }, ATRAS: 'tipoCliente' },
},
Conviene distinguir cuatro figuras, porque se escriben distinto y se equivocan distinto. El salto omite un paso y vuelve a la misma vía. El desvío mete un tramo entero que después reconverge. La bifurcación auténtica separa dos recorridos que ya no vuelven a encontrarse. Y el tramo repetible es el que se recorre varias veces, como añadir varios beneficiarios, y que en realidad no es una rama sino un bucle con condición de salida.
Salto
Un paso se omite y el flujo continúa igual. Es la figura más barata: una guarda y dos aristas desde el mismo origen.
Desvío que reconverge
Se insertan uno o varios pasos que terminan devolviendo al tronco. El punto de reunión merece nombre propio, porque es donde el retroceso tendrá que decidir de dónde venía.
Bifurcación real
Dos recorridos que no vuelven a juntarse y que probablemente terminan en envíos distintos. Suele ser señal de que había dos procedimientos y uno se disfrazó del otro.
Tramo repetible
Añadir un elemento más no es ramificar sino iterar. Se modela con una arista que vuelve al mismo paso y una guarda que decide cuándo se puede salir.
Es explícita, aparece dibujada como dos flechas saliendo del mismo nodo y no necesita ninguna construcción adicional. Su límite aparece cuando varias aristas distintas llevan al mismo punto de decisión: si tres pasos pueden desembocar en la pregunta de si hace falta verificar la identidad, la condición se repite tres veces y a la cuarta modificación una de las tres se queda atrás. Ese es el síntoma que pide la segunda forma.
El estado que decide y no se queda
Un estado enrutador es un nodo sin interfaz, sin permanencia y sin evento de entrada: se entra en él y se sale en el mismo instante lógico, porque sus transiciones se evalúan sin esperar a nada. Es el pseudoestado de elección de toda la vida, y su valor es que convierte una condición repartida en un punto único al que cualquiera puede apuntar.
trasDireccion: {
always: [
{ target: 'verificacion', guard: 'requiereVerificacion' },
{ target: 'pago', guard: 'tieneMetodoGuardado' },
{ target: 'metodoPago' },
],
},
Tres propiedades lo hacen distinto de un paso normal y conviene tenerlas presentes. La primera es que nunca se le ve: la máquina no reposa ahí, así que la interfaz jamás tiene que dibujarlo y ningún matches debería preguntarle. La segunda es que su última rama debe carecer de guarda, porque un enrutador donde todas las condiciones fallan deja la máquina detenida en un nodo que no era un destino legítimo. La tercera es que su nombre importa más de lo habitual: al leer el diagrama, un nodo llamado trasDireccion explica cuándo se decide, y uno llamado decidir no explica nada porque habrá cuatro.
| Criterio | Bifurcación en el origen | Estado enrutador |
|---|---|---|
| Dónde se escribe la condición | en cada transición saliente | una sola vez, en el nodo |
| Varias entradas al mismo dilema | duplica la guarda por cada origen | todas apuntan al mismo sitio |
| Visibilidad en el diagrama | dos flechas desde el paso | un rombo con sus salidas |
| Tipo de las salidas | guardada | por defecto |
idéntico, pero centralizado |
| Riesgo propio | condiciones que divergen entre orígenes | quedarse atrapado si falta la rama final |
El orden de ejecución importa y es fácil de asumir mal. Las acciones declaradas en la transición se ejecutan después de salir del estado de origen y antes de entrar en el destino, y las transiciones sin evento del destino se evalúan a continuación, ya con el contexto actualizado. Por eso el enrutador puede leer la respuesta que el paso anterior acaba de guardar. Lo que no puede es leer una respuesta que sigue viajando en el evento sin haber pasado por assign: la guarda del enrutador ve contexto, no eventos.
La respuesta que cambia después
Aquí está el problema que casi ningún asistente resuelve y que produce los errores más caros, porque no fallan en pantalla sino en los datos que llegan al servidor. El usuario dice que es una empresa, rellena los datos fiscales, sigue avanzando, y en la revisión retrocede y marca que es un particular. El flujo se recalcula bien: la próxima vez que avance, el enrutador saltará el paso fiscal. Pero el contexto sigue conteniendo el número de identificación fiscal que se recogió en la rama abandonada, y ese dato viajará en el envío final como si el usuario lo hubiera confirmado.
tipoCliente: {
on: {
ELEGIR: {
actions: assign(({ context, event }) => ({
tipo: event.tipo,
datos:
event.tipo === context.tipo
? context.datos
: { ...context.datos, nif: undefined, razonSocial: undefined },
})),
},
},
},
La regla general que hay detrás vale para cualquier flujo ramificado: los datos recogidos en una rama pertenecen a esa rama, y abandonar la rama debe llevárselos. Implementarlo exige haber declarado en algún sitio qué campos pertenecen a qué tramo, y esa declaración es útil por sí misma porque documenta el flujo mejor que cualquier comentario. La alternativa perezosa —dejar los datos huérfanos y filtrarlos justo antes de enviar— funciona mientras el filtro se acuerde de todos los casos, y deja de funcionar en cuanto aparece la segunda rama.
Con tres pasos opcionales que se activan por separado hay ocho recorridos posibles. La reacción equivocada es crear estados para las combinaciones; la correcta es entender que el número de nodos sigue siendo pequeño y lo que crece es el número de caminos por el grafo. Esa distinción es la que salva el modelo, y además es la que hace valioso el testing basado en el modelo: quien enumera caminos es la herramienta, y a mano nadie escribe los ocho.
stateDiagram-v2 [*] --> tipoCliente tipoCliente --> datosFiscales: SIGUIENTE si es empresa tipoCliente --> direccion: SIGUIENTE si es particular datosFiscales --> direccion: SIGUIENTE direccion --> trasDireccion: SIGUIENTE trasDireccion --> verificacion: requiere verificacion trasDireccion --> pago: metodo guardado trasDireccion --> metodoPago: por defecto verificacion --> metodoPago metodoPago --> pago pago --> revision
Progreso, retroceso y la forma del grafo
Dos cosas dejan de ser triviales cuando el flujo se ramifica. La primera es el indicador de progreso: no hay un total constante porque el total depende de respuestas que aún no se han dado. Hay tres salidas honestas y una deshonesta. Se puede calcular el recorrido restante aplicando las guardas al contexto actual, lo que da un total exacto pero variable y produce el efecto desconcertante de una barra que retrocede al añadirse un paso. Se puede mostrar solo el tramo confirmado. Se puede abandonar el porcentaje y enseñar nombres de paso, que es lo que hacen los asistentes largos bien diseñados. La deshonesta es fijar un total inventado y estirar la barra, y es la más común.
La segunda es el retroceso, que ya no es la inversa del avance. Si desde el tipo de cliente se pudo llegar a la dirección por dos caminos, la arista de vuelta desde la dirección tiene que decidir a cuál regresa, y esa decisión repite la condición del avance. La forma de no duplicarla es extraerla a una guarda con nombre que usen ambas direcciones, o usar el retroceso grabado del que hablaba la lección anterior, asumiendo entonces la obligación de podar la pila cuando una respuesta cambie de rama. Lo que no funciona es escribir las dos condiciones a mano por separado, porque nadie las mantiene sincronizadas más de dos sprints.
Queda una obligación menos visible: la pureza de las guardas. Un predicado que decide una rama tiene que ser una función de contexto y evento y nada más, sin leer la hora, sin consultar el almacenamiento y sin preguntar a un servicio. En cuanto una guarda depende de algo que la máquina no controla, el grafo deja de ser determinista y dos ejecuciones con el mismo historial pueden acabar en pasos distintos, con lo que se pierde justo lo que se había ganado: la posibilidad de recorrerlo, reproducirlo y probarlo. Si la rama depende de una consulta remota, esa consulta es un actor invocado en un estado anterior y su resultado se acumula en el contexto; la guarda solo lee lo que ya está ahí.
Una guarda con nombre declarada en setup se puede reutilizar en el avance, en el retroceso, en la barra de progreso y en la prueba. Escrita en línea como función anónima dentro de la transición, se copia y a partir de la segunda copia empieza la divergencia. Es la regla más aburrida del nivel y la que más asistentes ha salvado.
Un flujo lineal es una afirmación implícita muy fuerte: que todos los usuarios son el mismo usuario. En cuanto aparece la primera rama, esa afirmación se rompe y el asistente pasa a contener un modelo de su propia audiencia, con categorías —empresa o particular, con método guardado o sin él, de riesgo alto o bajo— que hasta entonces vivían en la cabeza de alguien. Escribir esas categorías como guardas con nombre las saca de la cabeza y las pone en un artefacto revisable, y ahí ocurre algo que trasciende la ingeniería: el grafo se convierte en el sitio donde el producto puede leer, discutir y corregir su propia teoría sobre a quién sirve. Un condicional escondido en el manejador de un botón no permite esa conversación, porque nadie que no programe lo va a encontrar, y las decisiones que nadie encuentra se toman una vez y no se revisan nunca. Hay además una consecuencia técnica que se sigue de la misma idea. Cuando la condición vive en el grafo, es un objeto de primera clase: se le puede poner nombre, probar en aislamiento, recorrer exhaustivamente y contar cuántos caminos genera. Cuando vive en el flujo de control imperativo, no es nada, y su efecto solo se descubre ejecutando. Por eso el asistente ramificado es la primera situación del track donde el testing basado en el modelo deja de ser una curiosidad académica y se vuelve la única forma razonable de trabajar: nadie enumera a mano los recorridos de un grafo con cinco decisiones, pero cualquier recorredor los enumera en milisegundos y encuentra el que combina empresa con recogida en tienda y verificación pendiente, que es exactamente el que nunca se probó y el que romperá en producción.
- Escribe la lista de frases condicionales que negocio dice sobre tu flujo y traduce cada una a una guarda con nombre.
- Implementa la primera con un array de transiciones y comprueba que la última rama, sin guarda, siempre atrapa el caso restante.
- Encuentra una decisión a la que lleguen dos pasos distintos y centralízala en un estado enrutador con transiciones sin evento.
- Verifica que la guarda del enrutador ve la respuesta recién acumulada y no el evento que la traía.
- Declara qué campos pertenecen a cada rama y poda el contexto cuando el usuario cambie la respuesta que la determina.
- Cuenta cuántos recorridos completos admite tu grafo, genera pruebas para todos y localiza el que nunca habías probado a mano.