wandres.dev
HISTORIA Y FINALES · recordar y terminar

Casos de uso de la historia: pestañas, pasos y el botón atrás

El mecanismo de historia se entiende de verdad cuando se aplica. Esta lección recorre los tres escenarios donde aparece casi siempre —pestañas que recuerdan su posición interna, asistentes por pasos que se pueden abandonar y retomar, y la navegación hacia atrás— y en cada uno decide la profundidad correcta con un argumento y no con una corazonada. Cierra con la confusión más cara del tema: la historia de un statechart no es una pila de navegación, recuerda un punto y no un recorrido, y confundir ambas cosas produce un botón atrás que miente.

⏱ 17 min

La sintaxis de un nodo de historia se aprende en cinco minutos; saber cuándo ponerlo, con qué profundidad y qué esperar de él lleva bastante más. La razón es que la historia resuelve un problema de experiencia de usuario, no de arquitectura, y los problemas de experiencia no se deducen del árbol de estados: hay que ir a mirar qué le duele perder a quien usa la aplicación. Tres escenarios cubren la práctica totalidad de los casos reales. Un contenedor de pestañas donde cada pestaña tiene vida interior. Un asistente por pasos que el usuario puede abandonar a mitad y retomar más tarde. Y la navegación hacia atrás, que parece el caso canónico de historia y resulta ser el que peor encaja, porque revela que la historia de Harel recuerda un punto y no un recorrido. Ver los tres juntos, con sus profundidades justificadas y su límite señalado, es lo que convierte el mecanismo en criterio.

🎯 Al terminar esta lección sabrás
  • Modelar un contenedor de pestañas con memoria interna y justificar la profundidad elegida.
  • Diseñar un asistente por pasos interrumpible separando la posición del árbol de los datos del contexto.
  • Explicar por qué la historia no es una pila y qué mecanismo cubre la navegación hacia atrás.
  • Decidir en cada caso entre historia, contexto y el enrutador según qué debe sobrevivir a qué.

Pestañas con vida interior

Un contenedor de pestañas es el caso más limpio. Cada pestaña es un subestado del compuesto panel, y salir del panel para ir a otra pantalla debe devolverte a la pestaña que tenías abierta. La pregunta interesante llega cuando una pestaña tiene a su vez modos internos —una tabla con filtros desplegados, un editor en modo previsualización— y hay que decidir si esos modos también vuelven.

import { createMachine } from 'xstate'

const ajustes = createMachine({
  id: 'ajustes',
  initial: 'panel',
  states: {
    panel: {
      initial: 'perfil',
      states: {
        perfil: { on: { IR_FACTURACION: 'facturacion' } },
        facturacion: {
          initial: 'resumen',
          states: {
            resumen: { on: { VER_HISTORIAL: 'historial' } },
            historial: { on: { VOLVER: 'resumen' } },
          },
          on: { IR_PERFIL: 'perfil' },
        },
        hist: { type: 'history', history: 'deep', target: 'perfil' },
      },
      on: { ABRIR_AYUDA: 'ayuda' },
    },
    ayuda: {
      on: { CERRAR_AYUDA: 'panel.hist' },
    },
  },
})

Con memoria profunda, abrir la ayuda estando en facturacion.historial y cerrarla te devuelve al historial. Con memoria superficial te devolvería a facturacion.resumen: la pestaña correcta, el interior perdido. Ninguna de las dos es la respuesta universal, y el criterio que las separa es sorprendentemente concreto: si el estado interno de la pestaña lo produjo el usuario, quiere recuperarlo; si lo produjo el sistema, puede regenerarse. Un historial que el usuario desplegó a propósito es suyo y debe volver; una pantalla de carga que estaba a medias no es de nadie y debe repetirse limpia.

stateDiagram-v2
[*] --> Panel
state Panel {
  [*] --> Perfil
  Perfil --> Facturacion: ir facturacion
  state Facturacion {
    [*] --> Resumen
    Resumen --> Historial: ver historial
    Historial --> Resumen: volver
  }
  Facturacion --> Perfil: ir perfil
}
Panel --> Ayuda: abrir ayuda
Ayuda --> Panel: cerrar via historia profunda
note right of Ayuda: la memoria profunda restaura pestana y modo interno
ℹ️
Historia o contexto: quién debe recordar la pestaña activa

Existe la alternativa evidente de guardar la pestaña activa en el contexto y transicionar hacia ella con un destino dinámico. Funciona, y es peor por tres motivos acumulativos. Uno: duplica una información que la configuración de estados ya tiene, con el riesgo clásico de que las dos copias se desincronicen. Dos: no escala a la profundidad, porque recordar la pestaña es un campo, pero recordar la pestaña y su modo interno y el modo interno de ese modo es reconstruir a mano el árbol entero. Tres: mete navegación en el contexto, que debería contener datos del dominio, y a partir de ahí cualquiera que lea el contexto tiene que distinguir qué campos son negocio y cuáles son mueble. La historia hace lo mismo sin ninguno de los tres costes porque no almacena nada nuevo: memoriza una configuración que ya existía.

Asistentes por pasos interrumpibles

Un asistente de alta —datos, plan, pago, confirmación— es un compuesto donde el orden importa y donde abandonar a mitad es normal. Aquí la historia profunda es casi siempre la elección correcta, porque el usuario que se marchó del paso tres para consultar algo espera volver al paso tres, no al uno. Pero el caso trae consigo la lección que separa a quien entendió el mecanismo de quien lo copió.

🧭

La posición vuelve

La historia restaura el paso exacto donde estabas dentro del asistente. Es topología: qué nodo del árbol estaba activo cuando saliste.

📦

Los datos no

Lo que habías escrito vive en el contexto. Si el exit del compuesto lo limpia, volverás al paso correcto con los campos vacíos.

🧱

Van juntos o no van

Un asistente interrumpible necesita las dos memorias coordinadas. Restaurar solo una produce una experiencia peor que no restaurar nada.

El esqueleto mínimo que respeta esa coordinación tiene dos piezas que se leen juntas: el nodo de historia dentro del compuesto y un contexto cuyo ciclo de vida no lo contradice.

import { createMachine, assign } from 'xstate'

const asistente = createMachine({
  id: 'asistente',
  initial: 'alta',
  context: { datos: {}, plan: null },
  states: {
    alta: {
      initial: 'paso1',
      states: {
        paso1: {
          on: {
            SIGUIENTE: {
              target: 'paso2',
              actions: assign({ datos: ({ event }) => event.datos }),
            },
          },
        },
        paso2: { on: { SIGUIENTE: 'paso3', ATRAS: 'paso1' } },
        paso3: { on: { CONFIRMAR: 'listo', ATRAS: 'paso2' } },
        listo: { type: 'final' },
        hist: { type: 'history', history: 'deep', target: 'paso1' },
      },
      on: { SALIR: 'inicio' },
      onDone: 'confirmado',
    },
    inicio: { on: { RETOMAR: 'alta.hist' } },
    confirmado: { type: 'final' },
  },
})

Nótese lo que NO aparece ahí: ninguna acción de salida que limpie el contexto del asistente. Es una omisión deliberada, porque el contexto tiene que sobrevivir exactamente el mismo tiempo que la memoria de historia. Y nótese también que RETOMAR apunta a alta.hist mientras que un hipotético EMPEZAR_DE_CERO apuntaría a alta a secas: el mismo compuesto ofrece dos puertas de entrada con semánticas opuestas, y tenerlas separadas y visibles es lo que permite ofrecer al usuario ambas opciones sin escribir ninguna lógica condicional.

Esa coordinación es un requisito de diseño, no un detalle. Si limpias el contexto al salir del asistente para no arrastrar basura entre sesiones, la historia te devolverá al paso tres con el formulario en blanco, y el usuario se encontrará ante un estado que la interfaz no sabe explicar: avanzado en el recorrido, vacío en los datos. La regla práctica es que el ciclo de vida del contexto del asistente debe coincidir con el alcance de la memoria de historia; si uno se borra, el otro también, y si uno sobrevive, el otro igual. Un exit que hace assign de valores iniciales es incompatible con una historia profunda apuntando al mismo compuesto, y detectar esa incompatibilidad antes de escribirla ahorra una tarde de depuración desconcertante.

Escenario Profundidad Razón de la elección
Pestañas sin modos internos shallow Solo hay un nivel, shallow | deep son indistinguibles
Pestañas con modos del usuario deep El modo interno es información que el usuario produjo
Asistente por pasos deep El paso exacto es el valor entero de recordar
Diálogo modal reabierto ninguna Debe arrancar limpio, el olvido es la funcionalidad

El botón atrás no es historia

Aquí está la confusión más cara del tema, y merece decirse sin rodeos: la historia de un statechart no es una pila. Memoriza una única configuración por nodo de historia, la última, y la sobrescribe en cada salida. No guarda un recorrido, no permite retroceder dos posiciones, no tiene noción de orden. El botón atrás del navegador, en cambio, es una pila LIFO de entradas de navegación donde cada retroceso desapila una. Son estructuras de datos distintas resolviendo problemas distintos, y confundirlas produce un atrás que a veces acierta por casualidad y a veces salta a un sitio que el usuario nunca visitó.

⚠️
Un atrás real necesita una pila explícita

Si quieres navegación hacia atrás de verdad, el mecanismo es otro: mantén una pila en el contexto, apila la configuración al entrar en cada paso y desapila al recibir ATRAS. Es más código y es correcto, porque el problema pide una estructura ordenada y la historia solo ofrece un registro puntual. Existe una tercera vía, más idiomática todavía: dejar el recorrido en manos del enrutador —que ya es una pila y ya está integrada con el navegador— y usar la máquina solo para el estado de cada pantalla, sincronizando la URL con el estado activo. Repartir así las responsabilidades evita reimplementar una pila que el navegador te regala, y deja a la historia el único trabajo que hace bien: recordar el punto exacto dentro de una pantalla a la que vuelves.

📝
Deshacer tampoco es historia

Por la misma razón, la historia no sirve para implementar deshacer. Deshacer necesita el registro completo de transiciones o una pila de snapshots, porque su unidad es el cambio y no la posición. La versión rigurosa se construye guardando cada snapshot en una lista y rehidratando el actor con el anterior cuando llega el evento de deshacer, técnica que funciona precisamente porque un snapshot de XState es un valor serializable completo. La historia recuerda dónde estabas la última vez que saliste de un sitio; deshacer necesita saber cómo llegaste hasta aquí. Que ambos usen la palabra memoria no los hace el mismo mecanismo, y elegir el equivocado se nota tarde y duele.

Cada memoria tiene su alcance, y el diseño consiste en repartirlas

Los tres casos de esta lección son en el fondo el mismo ejercicio repetido: decidir qué debe sobrevivir a qué. Un sistema interactivo real no tiene una memoria, tiene un pequeño ecosistema de memorias con alcances distintos y duraciones distintas, y el trabajo del que diseña no es elegir la más potente sino asignar cada dato a la que le corresponde. La configuración activa recuerda el ahora y muere en cada transición. La historia recuerda el último punto de un compuesto y sobrevive a salidas y reentradas de ese compuesto. El contexto recuerda datos y sobrevive mientras el actor viva. El snapshot persistido recuerda todo lo anterior y sobrevive a la recarga de la página. La pila del enrutador recuerda el recorrido y sobrevive al botón atrás. La base de datos recuerda lo que debe sobrevivir al usuario. Cinco alcances, cinco duraciones, y casi todos los bugs desconcertantes de una interfaz compleja consisten en haber puesto un dato en el alcance equivocado: la pestaña activa en la base de datos, el borrador del formulario solo en la configuración activa, el recorrido de navegación en un nodo de historia que solo guarda un punto. Cuando alguien pregunta si debe usar historia o contexto está haciendo, sin saberlo, la pregunta correcta —cuál es el alcance de este recuerdo— y la respuesta nunca sale de la documentación de la librería, sale de mirar el producto y preguntarse qué le resultaría absurdo al usuario perder y qué le resultaría absurdo conservar. Esa sensibilidad para los alcances es lo que distingue un modelo que se siente natural de uno que técnicamente funciona y aun así irrita en cada uso.

⚔️ Reparte las memorias de un flujo real
  1. Modela un panel de ajustes con tres pestañas, dos de ellas con modos internos, y justifica por escrito la profundidad que eliges para su nodo de historia.
  2. Añade una pantalla de ayuda que se abra y se cierre, y comprueba que al cerrarla vuelves al modo interno exacto.
  3. Construye un asistente de cuatro pasos con datos en el contexto e historia profunda; abandónalo en el paso tres y verifica que vuelves al paso tres con los datos intactos.
  4. Introduce deliberadamente un exit que limpie el contexto del asistente y describe la experiencia rota que produce combinado con la historia profunda.
  5. Implementa un botón atrás con una pila explícita en el contexto y compáralo con lo que habría hecho el nodo de historia en la misma secuencia de tres saltos.
  6. Sincroniza la pestaña activa con la URL usando el enrutador y decide, con argumentos, qué parte del recuerdo queda en el enrutador y cuál en la máquina.
  7. Escribe la tabla de alcances de tu propio flujo: qué dato vive en configuración, en historia, en contexto, en el snapshot persistido y en el servidor.