Navegación hacia atrás: volver sin perder nada
Retroceder en un asistente destruye dos cosas distintas y conviene no confundirlas: los datos que el usuario escribió, que sobreviven si viven donde deben, y la posición interna del paso compuesto, que se destruye siempre salvo que un nodo de historia lo impida. Esta lección separa ambas pérdidas, muestra por qué el estado local del componente es el verdadero culpable de la primera, aplica historia superficial y profunda a pasos con subpasos, contrapone la arista declarada con la pila grabada y explica por qué retroceder nunca puede significar deshacer.
El botón de volver es la funcionalidad más barata de prometer y la más cara de cumplir. Prometerla cuesta una flecha en el diagrama; cumplirla obliga a responder a qué se vuelve exactamente, porque un paso no es un punto sino un conjunto de cosas que ocurrían a la vez: lo que el usuario había escrito, dónde tenía el foco, qué subpantalla del paso estaba abierta, qué validaciones ya habían pasado. La implementación descuidada devuelve al usuario al nombre del paso y nada más, y el efecto es demoledor porque parece un castigo: quien retrocede para corregir una letra encuentra el formulario en blanco y aprende, muy rápido, a no volver nunca. Las dos pérdidas tienen causas técnicas distintas y remedios distintos, y separarlas es la mitad del trabajo.
- Distinguir la pérdida de datos de la pérdida de posición interna y atribuir cada una a su causa.
- Situar los datos del asistente en
contexty no en el estado local del componente que se desmonta. - Restaurar la configuración interior de un paso compuesto con historia superficial o profunda.
- Contrastar la arista de retroceso declarada con la pila grabada y elegir con criterio entre ambas.
Dos pérdidas con causas distintas
La primera pérdida, la de los datos, casi nunca es culpa de la máquina. El contexto pertenece al actor entero y no a ningún estado, así que una transición entre pasos hermanos no lo toca: lo que se guardó al avanzar sigue ahí al volver. Si el formulario aparece vacío, el culpable es otro y suele ser el mismo siempre: el paso guardaba lo que el usuario escribía en el estado local de su componente, y ese componente se desmontó al cambiar de pantalla. El remedio no es un truco de persistencia, es reconocer que el asistente tiene un dueño de los datos y que ese dueño es el actor, no la vista.
Un asistente cuyos pasos mantienen sus propios campos internos y solo los entregan al pulsar continuar funciona perfectamente hacia adelante y pierde todo hacia atrás. El patrón correcto es el inverso: el componente se inicializa leyendo del contexto y devuelve al contexto cada cambio significativo, o al menos el parche completo al salir. La regla operativa es sencilla y no admite excepciones cómodas: si un dato tiene que sobrevivir a un cambio de paso, no puede vivir en el paso.
export function crearBorradorDireccion(actor: ActorRefFrom<typeof alta>) {
const guardado = actor.getSnapshot().context.datos
const borrador = {
calle: guardado.calle ?? '',
ciudad: guardado.ciudad ?? '',
}
return {
borrador,
escribir: (campo: 'calle' | 'ciudad', valor: string) => {
borrador[campo] = valor
},
continuar: () => actor.send({ type: 'SIGUIENTE', parche: borrador }),
}
}
El borrador local sigue existiendo, y está bien que exista: teclear no tiene por qué producir una transición. Lo que cambia es su origen y su destino. Se inicializa desde el contexto, de modo que volver lo repuebla, y se entrega al contexto al avanzar, de modo que salir no lo pierde. El componente pasa a ser una vista temporal sobre un dato que vive en otro sitio, que es justo lo que debía ser.
El contexto no se resetea solo
Una transición entre pasos hermanos no toca el contexto. Si tus datos desaparecen, alguien los está borrando explícitamente o nunca llegaron a estar ahí.
El componente es una ventana
Se desmonta, y con él todo lo que guardase en su interior. Cualquier dato que deba sobrevivir al cambio de paso tiene que estar fuera de la vista antes de que la vista muera.
La posición interior sí se destruye
Salir de un compuesto borra su configuración. Es el mecanismo correcto por defecto y el nodo de historia existe justamente para desactivarlo donde estorba.
Recordar y olvidar son decisiones
Restaurar un subestado de error o una comprobación en curso es tan indeseable como perder los campos. La memoria se coloca donde interesa, no en todas partes.
La segunda pérdida sí es responsabilidad del modelo y es estructural. Salir de un estado compuesto destruye su configuración interior: la máquina abandona la hoja, luego el padre, y al reentrar arranca por el subestado inicial. Mientras cada paso sea una pantalla plana eso da igual, porque no hay interior que perder. En cuanto un paso tiene subpasos —el pago que primero elige método y después pide los datos de la tarjeta, la dirección que ofrece buscar por código postal o rellenar a mano— volver a ese paso significa volver a su principio, y el usuario que solo quería revisar un dígito se encuentra otra vez eligiendo método de pago.
Historia para lo que el paso tenía dentro
El pseudoestado de historia cancela ese olvido. Declarado como hijo del compuesto, no es un lugar donde la máquina repose sino una instrucción de reentrada que se resuelve en el mismo microstep: consulta qué configuración había grabada al salir y entra directamente allí.
pago: {
initial: 'metodo',
states: {
metodo: { on: { ELEGIR: 'tarjeta' } },
tarjeta: {
initial: 'numero',
states: {
numero: { on: { CONTINUAR: 'titular' } },
titular: { on: { ATRAS: 'numero' } },
},
on: { CAMBIAR_METODO: 'metodo' },
},
hist: { type: 'history', history: 'deep', target: 'metodo' },
},
on: { SIGUIENTE: 'revision', ATRAS: 'direccion' },
},
revision: {
on: { ATRAS: 'pago.hist', CONFIRMAR: 'enviando' },
},
La diferencia entre las dos profundidades se decide por una única pregunta: cuántos niveles quiero restaurar. La superficial devuelve al hijo directo que estaba activo y deja que ese hijo arranque por su propio inicial; la profunda restaura el camino entero hasta la hoja. Para un paso con un solo nivel interior son equivalentes; para un subasistente como el del ejemplo, la superficial devolvería al usuario al bloque de la tarjeta pero al primer campo, y la profunda lo devuelve exactamente donde estaba. El target del nodo cubre la primera reentrada, cuando aún no hay nada grabado, y omitirlo es la causa habitual del error al volver por primera vez a un paso que se saltó.
stateDiagram-v2
state pago {
[*] --> metodo
metodo --> tarjeta: ELEGIR
state tarjeta {
[*] --> numero
numero --> titular: CONTINUAR
titular --> numero: ATRAS
}
tarjeta --> metodo: CAMBIAR_METODO
}
pago --> revision: SIGUIENTE
revision --> pago: ATRAS via historia profundaNo todo debe restaurarse. Si el paso contiene una comprobación asíncrona o un subestado de error, restaurar exactamente esa posición devuelve al usuario a un mensaje viejo sobre un intento antiguo. La solución es no apuntar la historia a la raíz del compuesto sino colocar el nodo en el nivel donde la memoria es deseable, dejando fuera los subestados transitorios. Olvidar es una decisión de diseño tan explícita como recordar.
La arista declarada y la pila grabada
Hay dos filosofías para responder a qué significa atrás, y la mayoría de los asistentes rotos mezclan ambas sin darse cuenta. La arista declarada afirma cuál es el paso anterior en sentido lógico: lo escribe el modelador, aparece en el diagrama y es estable. La pila grabada guarda el recorrido real del usuario en el contexto y retrocede por él, sea cual sea el camino que tomó.
| Criterio | Arista declarada | Pila grabada |
|---|---|---|
| Dónde vive | en el grafo, junto al paso | en context, como lista de nombres |
| Con ramas condicionales | duplica la condición para saber de dónde venía | acierta siempre, porque lo presenció |
| Legibilidad del diagrama | visible como flecha | invisible, hay que leer código |
| Riesgo propio | ramas que retroceden a un paso que no se visitó | volver a un paso que ya no aplica tras cambiar una respuesta |
| Tipo de la memoria | ninguna, es estructura | string[], y hay que podarla |
| Naturaleza | semántica: el anterior lógico | histórica: el anterior real |
El conflicto es real y no tiene ganador universal. Con un flujo lineal la arista declarada es superior en todo: se ve en el dibujo, no añade estado y no puede desincronizarse. En cuanto hay ramas, la arista obliga a repetir en el retroceso la misma condición que gobernó el avance, y esa duplicación es donde aparecen los fallos de asistentes ramificados: se cambia la condición de ida y nadie ajusta la de vuelta. La pila no puede equivocarse sobre el pasado, pero introduce un problema propio y más sutil: si el usuario retrocede varios pasos, cambia una respuesta que altera la rama y vuelve a avanzar, la pila conserva pasos que ya no pertenecen al recorrido válido. Quien elige la pila debe podarla al modificar cualquier respuesta que gobierne una bifurcación, y quien elige aristas debe extraer la condición a una guarda con nombre que usen las dos direcciones.
Falta un tercer actor que nadie invitó y que siempre aparece: el botón de retroceso del navegador. Si cada paso tiene su propia entrada en el historial del navegador, el usuario dispone de dos mecanismos de vuelta con memorias distintas, y en cuanto discrepan el asistente se comporta de forma impredecible. La única arquitectura estable es la que decide quién manda: la máquina es la fuente de verdad, la barra de direcciones es una proyección suya que se actualiza al entrar en cada paso, y el evento de retroceso del navegador no navega a ningún sitio sino que se traduce a un ATRAS ordinario que la máquina resolverá según sus propias reglas.
Si el enrutador conduce y la máquina obedece, una dirección tecleada a mano puede colocar al usuario en el paso de pago con el contexto vacío, y ninguna guarda lo impedirá porque nadie tomó una transición. Con la máquina al mando, la ruta se sincroniza al entrar en el paso y una dirección profunda se contrasta contra el modelo antes de aceptarse: si no se puede llegar ahí desde el estado guardado, se redirige al último paso alcanzable.
Volver no es deshacer
La confusión más peligrosa del patrón es tratar el retroceso como una operación de deshacer. Navegar hacia atrás mueve la posición y conserva los datos; deshacer revierte efectos. Mientras el asistente solo recoja información, ambas coinciden y nadie nota la diferencia. En cuanto un paso produce algo fuera —una reserva temporal, un cobro autorizado, un documento firmado, un correo enviado— dejan de coincidir por completo, y una flecha de retroceso dibujada sin pensarlo promete algo que el sistema no puede cumplir.
La consecuencia de diseño es que los pasos con efectos irreversibles no llevan arista de vuelta. Una vez cruzado el punto de no retorno, la salida no es volver sino cancelar, y cancelar es una transición distinta con su propia acción compensatoria: liberar la reserva, anular la autorización, registrar la renuncia. Ese es el motivo por el que el envío merece una lección propia y por el que la pantalla de revisión es el último lugar del flujo desde el que retroceder sigue siendo gratis.
Un asistente que pierde datos al retroceder no tiene un fallo de persistencia: tiene un modelo que nunca separó dónde está el usuario de qué ha dicho el usuario. Cuando esas dos cosas viven juntas —los campos dentro del componente del paso, el progreso dentro del árbol de la vista— moverse por el flujo implica destruir información, porque el continente y el contenido son el mismo objeto y desmontar uno se lleva el otro. La separación entre configuración y contexto, que en las primeras lecciones parecía una peculiaridad de la librería, se revela aquí como lo que siempre fue: la condición para que el movimiento sea seguro. Y esa condición tiene un correlato conceptual más profundo. Una máquina con contexto es una máquina finita en su control y potencialmente infinita en sus datos, y esa asimetría es exactamente lo que permite razonar sobre ella: el conjunto de posiciones es enumerable, recorrible y dibujable, mientras el conjunto de valores no lo es ni falta que hace. Meter datos en la identidad del estado destruye la finitud y con ella toda posibilidad de análisis; meter la posición en los datos destruye la estructura y con ella toda garantía sobre los caminos. La historia, por su parte, enseña la otra mitad: hay memoria que el sistema debe conservar y memoria que debe tirar, y decidirlo caso por caso es diseño, no infraestructura. Un producto que recuerda todo es tan hostil como uno que no recuerda nada, porque devuelve al usuario a errores viejos y a decisiones que ya había cambiado. Modelar bien la vuelta atrás es, en el fondo, aceptar que el olvido selectivo es una función del sistema y merece declararse con la misma precisión que el avance.
- Recorre tu asistente hacia atrás desde el último paso y anota exactamente qué información desaparece en cada salto.
- Localiza los campos que viven en el estado local de un componente y muévelos al contexto, inicializando la vista desde él.
- Convierte en compuesto un paso que tenga subpantallas y comprueba que al volver arranca por su inicial en lugar de por donde estabas.
- Añade un nodo de historia con destino por defecto y decide de forma razonada si te hace falta superficial o profunda.
- Excluye de la memoria de historia los subestados de comprobación y error, y verifica que no reaparecen mensajes viejos.
- Elige entre arista declarada y pila grabada para un flujo con al menos una rama, e implementa la poda o la guarda compartida que tu elección exige.