wandres.dev
HISTORIA Y FINALES · recordar y terminar

Estados finales: declarar que una región terminó

No todo autómata corre para siempre. La quíntupla clásica incluye un conjunto de estados de aceptación, y XState lo materializa con `type: 'final'`. Esta lección precisa qué significa exactamente marcar un estado como terminal: que no admite salidas, que sus acciones de entrada sí se ejecutan, que al alcanzarlo el compuesto que lo contiene se declara completado y emite un evento sintético, que un estado paralelo solo termina cuando terminan todas sus regiones, y que un final en la raíz detiene el actor entero y sella su comportamiento para siempre.

⏱ 17 min

Un interruptor no termina nunca: alterna indefinidamente y su modelo es un ciclo. Pero la mayoría de los procesos que valen la pena modelar sí tienen final —un pago se completa, un asistente concluye, una subida acaba, una sesión se cierra— y ese final necesita estar en el modelo, no en un comentario. La teoría de autómatas lo contempló desde el principio: la quíntupla incluye un conjunto de estados de aceptación, los estados donde la máquina se detiene habiendo reconocido su entrada. XState lo expresa con una sola propiedad, type: 'final', y esa propiedad hace tres cosas a la vez que conviene separar mentalmente antes de usarlas. Prohíbe las transiciones de salida, porque aceptar es detenerse. Declara completada la región que contiene al estado, emitiendo un evento sintético que su contenedor puede escuchar. Y, si la región es la raíz, detiene el actor. Tres efectos, un mismo concepto: terminar.

🎯 Al terminar esta lección sabrás
  • Marcar un estado como terminal y explicar por qué no admite transiciones de salida.
  • Reconocer que un compuesto se completa cuando alcanza su final y emite un evento sintético.
  • Determinar cuándo termina un estado paralelo y por qué es la conjunción de sus regiones.
  • Interpretar el status de un actor que ha alcanzado un final de raíz y sus consecuencias.

El estado sin salida

Un estado terminal se declara con una propiedad y se caracteriza por una ausencia: no tiene on. La ausencia no es una limitación que soportemos, es la definición. Si un estado de aceptación pudiera seguir transicionando, no marcaría el final de nada; sería un estado intermedio con nombre pomposo.

import { createMachine } from 'xstate'

const subida = createMachine({
  id: 'subida',
  initial: 'inactiva',
  states: {
    inactiva: { on: { EMPEZAR: 'subiendo' } },
    subiendo: {
      on: { OK: 'completada', ERROR: 'fallida', CANCELAR: 'cancelada' },
    },
    completada: { type: 'final', entry: 'registrarExito' },
    fallida: { type: 'final', entry: 'registrarFallo' },
    cancelada: { type: 'final' },
  },
})

Tres detalles de esa declaración merecen atención. El primero es que un estado terminal sí ejecuta sus acciones de entrada: registrarExito corre con normalidad al llegar. Terminar no significa no hacer nada, significa no ir a ninguna parte, y el último acto de un proceso suele ser precisamente el más importante. El segundo es que puede haber varios finales distintos en la misma región, y distinguirlos es información valiosa: terminar bien, terminar mal y terminar por decisión del usuario son tres desenlaces con consecuencias diferentes aguas arriba. El tercero es que un final tiene exit declarable pero solo se ejecutará si el compuesto que lo contiene es abandonado desde fuera, nunca por iniciativa propia.

stateDiagram-v2
[*] --> Inactiva
Inactiva --> Subiendo: empezar
Subiendo --> Completada: ok
Subiendo --> Fallida: error
Subiendo --> Cancelada: cancelar
Completada --> [*]
Fallida --> [*]
Cancelada --> [*]
note right of Subiendo: tres desenlaces distintos y ninguno tiene transiciones de salida

Hay una lectura teórica que ilumina todo lo demás. En teoría de autómatas los estados finales son los estados de aceptación: la máquina lee una secuencia de símbolos y se dice que la acepta si termina en uno de ellos. Traducido, la secuencia de eventos que llevó al actor hasta un final es una secuencia aceptada, una historia válida y completa del proceso. Una subida que acaba en completada es una traza que el modelo reconoce como subida bien formada. Esa es, literalmente, la noción de aceptación de los cursos de lenguajes formales, y explica por qué modelar los finales con nombres distintos equivale a definir varios lenguajes aceptados por la misma máquina.

Terminar una región

El estado terminal despliega todo su poder dentro de un compuesto. Cuando la submáquina de un estado compuesto alcanza su propio final, ese compuesto se declara completado y el motor emite un evento sintético con la forma done.state.<id>, dirigido al padre. El padre no vigila los detalles internos del hijo ni escucha eventos del exterior: reacciona a una señal que significa exactamente “lo que había aquí dentro ha terminado”.

const registro = createMachine({
  id: 'registro',
  initial: 'formulario',
  states: {
    formulario: {
      initial: 'datos',
      states: {
        datos: { on: { SIGUIENTE: 'plan' } },
        plan: { on: { SIGUIENTE: 'revision' } },
        revision: { on: { CONFIRMAR: 'listo' } },
        listo: { type: 'final' },
      },
      onDone: 'bienvenida',
    },
    bienvenida: { type: 'final' },
  },
})

La composición es estructural y no depende de ningún actor externo: el compuesto formulario termina cuando su interior llega a listo, y esa terminación mueve al padre a bienvenida. Lo que hace valiosa la construcción es la dirección de la dependencia. El padre conoce el hecho de que su hijo terminó, pero no cómo lo hizo ni cuántos pasos tuvo; puedes reordenar, añadir o eliminar pasos internos sin tocar una línea del padre mientras siga existiendo un camino hasta el final. Es encapsulación real, con la frontera puesta donde debe estar.

💡
Un final es la única salida honesta de un compuesto

Un compuesto puede abandonarse de dos maneras. La primera es una transición declarada en el padre que lo saca por la fuerza, sin importar en qué subestado estuviera; sirve para cancelaciones y errores globales, y es una interrupción. La segunda es alcanzar el final interno y dejar que el compuesto se declare completado. La diferencia semántica es enorme aunque el resultado visible sea parecido: interrumpir dice “esto se acabó porque algo de fuera lo decidió”, completar dice “esto terminó su trabajo”. Cuando el padre necesita distinguir un abandono de una conclusión —y casi siempre lo necesita, aunque solo sea para no marcar como completado lo que se canceló— modelar la conclusión como final y la cancelación como transición explícita es lo que mantiene la distinción visible en el diagrama.

Regiones paralelas y la raíz

La finalización se compone también con el paralelismo, y ahí sigue una regla de conjunción lógica: un estado type: 'parallel' se considera terminado, y dispara su onDone, solo cuando cada una de sus regiones ha alcanzado su propio estado final. No basta con que una acabe, ni con que acaben la mayoría. El subproceso completo termina cuando terminan todas sus ramas simultáneas, lo cual convierte al paralelo con finales en el equivalente declarativo de esperar a que varias tareas concurrentes confluyan.

const arranque = createMachine({
  id: 'arranque',
  type: 'parallel',
  states: {
    sesion: {
      initial: 'cargando',
      states: {
        cargando: { on: { SESION_OK: 'lista' } },
        lista: { type: 'final' },
      },
    },
    config: {
      initial: 'cargando',
      states: {
        cargando: { on: { CONFIG_OK: 'lista' } },
        lista: { type: 'final' },
      },
    },
  },
  onDone: { actions: 'arrancarApp' },
})

Cuando el estado final está en la raíz, el efecto es de otra magnitud: el actor entero se detiene. Su status deja de ser active y pasa a done, los eventos que le envíes se ignoran en silencio, sus suscriptores reciben la notificación de finalización y todos los actores que hubiera invocado se detienen con él. Un actor en done es inmutable de hecho: su comportamiento ha quedado sellado y ninguna interacción posterior puede alterarlo.

Valor de status Significado Cómo se llega
active El actor corre y acepta eventos Estado normal tras start
done Alcanzó un final de raíz Transición a un type: 'final' de nivel superior
error Falló de forma no recuperable Excepción no capturada en una acción o actor
stopped Lo detuvieron desde fuera Llamada a stop sobre el actor o su padre

La distinción entre esos cuatro valores no es cosmética: cada uno cuenta una historia distinta que tu interfaz debería contar de forma distinta, porque terminar bien no es fallar y que te detengan desde fuera no es haber concluido. Colapsarlos en un booleano terminado es exactamente el tipo de pérdida de información que este track lleva ocho niveles combatiendo, y la ironía es que se comete con más frecuencia en el ciclo de vida del actor que en el dominio, precisamente porque parece infraestructura y no modelo.

🏁

Sin salida

Un final no declara on. Aceptar es detenerse, y un estado de aceptación con transiciones no marcaría el final de nada.

🔔

Avisa hacia arriba

Al alcanzarlo, el compuesto que lo contiene se declara completado y emite un evento sintético para su contenedor.

🛑

Detiene en la raíz

Un final de nivel superior sella el actor: pasa a done, ignora eventos y detiene con él a todo lo que hubiera invocado.

⚠️
Un fallo terminal no siempre debe ser un estado final

Es tentador marcar fallida como type: 'final' por simetría con completada, y a veces es correcto, pero conviene pensarlo dos veces. Si en la raíz declaras final el estado de error, el actor se detiene y ya no podrá reintentar: reintentar exigirá crear un actor nuevo desde cero, perdiendo el contexto acumulado. Si el fallo es recuperable —y la mayoría lo son— el estado de error debe ser un estado normal con una transición de reintento, y el final debe reservarse para el desenlace del que de verdad no se vuelve. La pregunta que decide es directa: ¿existe algún evento que un usuario razonable pudiera enviar desde aquí? Si lo hay, no es un final; es un estado que todavía escucha.

Terminar es lo que convierte una máquina en una pieza

La letra F de la quíntupla es la que parece menos interesante en los cursos de teoría —la que decide si una cadena se acepta— y resulta ser la que sostiene toda la arquitectura de sistemas grandes. La razón es que una máquina que nunca termina solo se puede observar, mientras que una máquina que termina se puede componer. Mientras un autómata sea un bucle perpetuo, lo único que puedes hacer con él es suscribirte a sus cambios y reaccionar desde fuera; su relación con el resto del sistema es de vigilancia. Pero en cuanto tiene un final, su forma cambia de golpe: recibe eventos como argumentos a lo largo del tiempo y, al terminar, se comporta como algo que ha retornado. Y lo que retorna se compone, porque componer es precisamente encadenar cosas que terminan. Esa es la operación que habilita el resto del nivel y buena parte de lo que viene después: máquinas que invocan máquinas que invocan máquinas, cada una con su principio, su cómputo y su fin, cada una entregando su conclusión a la de arriba y liberando sus recursos al hacerlo. Sin estados finales, un statechart sería una manera elegante de dibujar bucles reactivos, útil pero plana, y el sistema entero tendría que vivir en una sola máquina gigante porque no habría forma de que una parte le dijera a otra que ya está. Con ellos, el modelo adquiere jerarquía verdadera: procesos que contienen procesos, con fronteras que significan algo y responsabilidades que se pueden delegar hacia abajo sin que quien delega tenga que saber cómo se hace el trabajo. La terminación no es el borde de la máquina donde se acaba lo interesante; es su interfaz, y una interfaz es lo único que hace falta para que algo se componga con otra cosa.

⚔️ Modela desenlaces que signifiquen algo
  1. Escribe la máquina de subida con tres finales distintos y comprueba que enviar eventos tras alcanzar cualquiera de ellos no produce ningún cambio.
  2. Añade una acción de entrada a cada final y verifica en la traza que se ejecuta al llegar, pese a tratarse de estados terminales.
  3. Envuelve la subida dentro de un compuesto tarea y usa onDone para pasar a notificando; añade luego un paso intermedio y confirma que el padre no necesita cambiar.
  4. Modela una cancelación como transición del padre que saca del compuesto por la fuerza y explica en qué se diferencia, para el padre, de haber alcanzado el final.
  5. Construye un estado paralelo con dos regiones y demuestra con una traza que el onDone solo se dispara cuando ambas han llegado a su final.
  6. Alcanza un final de raíz y observa el status del snapshot; comprueba además que un actor invocado por esa máquina se detiene con ella.
  7. Toma un estado de error que hayas marcado como final y decide, con la pregunta del reintento, si merecía serlo o si debía seguir escuchando.