wandres.dev
HISTORIA Y FINALES · recordar y terminar

Componer con final y output: el patrón de subflujo

Cuando una máquina invocada declara qué recibe con `input` y qué devuelve con `output`, deja de ser un fragmento del árbol y se convierte en una unidad con firma: un subflujo. Esta lección construye el patrón completo —invocación, contrato de entrada y salida, reacción del padre con `onDone` y `onError`, cancelación gratuita al abandonar el estado— y después aplica el criterio que de verdad importa: cuándo merece la pena extraer un compuesto a subflujo, cuándo el coste de la frontera no se paga, y qué señales indican que la relación pedía comunicación continua en lugar de invocación.

⏱ 19 min

Todo lo anterior converge en un único patrón, y es el que sostiene cualquier sistema de estados grande que se mantenga legible. Una máquina declara qué necesita para empezar y qué produce al terminar; otra la invoca desde un estado concreto, le entrega lo primero y espera lo segundo. Eso es un subflujo, y la palabra importante es contrato: en cuanto existen input y output, la máquina hija tiene una firma, y una firma es lo que permite usarla sin conocer su interior, probarla por separado, sustituirla por otra equivalente y razonar sobre el padre sin abrir al hijo. Sin ese contrato tendrías dos máquinas acopladas que se manosean el contexto mutuamente y que hay que entender a la vez; con él tienes composición del mismo tipo que llevas usando desde que aprendiste a escribir funciones. La diferencia con una función es que el subflujo tarda, puede cancelarse a mitad y sigue vivo mientras el padre también lo está, y esas tres diferencias son exactamente las que hacen falta para modelar procesos reales.

🎯 Al terminar esta lección sabrás
  • Invocar una máquina como subflujo pasándole datos con input y recogiendo su output.
  • Reaccionar en el padre a la terminación con onDone y al fallo con onError.
  • Aprovechar la cancelación automática que produce abandonar el estado que invoca.
  • Aplicar un criterio para decidir cuándo extraer un compuesto a subflujo y cuándo no.

El subflujo y su contrato

Una máquina invocable se escribe igual que cualquier otra, con dos añadidos que la convierten en unidad reutilizable: declara el tipo de su entrada y produce un resultado al terminar. En XState v5 esto se expresa con setup para los tipos y con input en el contexto inicial.

import { setup, assign } from 'xstate'

const verificacion = setup({
  types: {
    input: {} as { email: string },
    context: {} as { email: string; intentos: number },
    output: {} as { verificado: boolean; email: string },
  },
}).createMachine({
  id: 'verificacion',
  context: ({ input }) => ({ email: input.email, intentos: 0 }),
  initial: 'esperandoCodigo',
  states: {
    esperandoCodigo: {
      on: {
        CODIGO_OK: 'verificado',
        CODIGO_MAL: {
          target: 'esperandoCodigo',
          actions: assign({ intentos: ({ context }) => context.intentos + 1 }),
        },
        RENDIRSE: 'abandonado',
      },
    },
    verificado: {
      type: 'final',
      output: ({ context }) => ({ verificado: true, email: context.email }),
    },
    abandonado: {
      type: 'final',
      output: ({ context }) => ({ verificado: false, email: context.email }),
    },
  },
})

Léelo como una firma y la analogía se vuelve literal: recibe un correo, devuelve si quedó verificado. Todo lo de en medio —cuántos intentos, qué eventos, qué estados intermedios— es interior privado del subflujo, y el padre no debe conocerlo ni depender de ello. Esa privacidad no es una recomendación de estilo, es la propiedad que hace que el subflujo se pueda modificar sin auditar a quien lo usa.

Invocar, esperar y recoger

El padre invoca el subflujo desde el estado donde tiene sentido esperarlo. La invocación declara la fuente, la entrada calculada a partir del contexto del padre y las dos reacciones posibles.

import { setup, assign } from 'xstate'

const alta = setup({
  actors: { verificacion },
}).createMachine({
  id: 'alta',
  initial: 'datos',
  context: { email: '', verificado: false },
  states: {
    datos: {
      on: { ENVIAR: 'verificando' },
    },
    verificando: {
      invoke: {
        src: 'verificacion',
        input: ({ context }) => ({ email: context.email }),
        onDone: {
          target: 'resultado',
          actions: assign({ verificado: ({ event }) => event.output.verificado }),
        },
        onError: 'errorTecnico',
      },
      on: { CANCELAR: 'datos' },
    },
    resultado: {},
    errorTecnico: {},
  },
})

Fíjate en la asimetría de las dos salidas. onDone responde a que el subflujo terminó, sea cual sea su desenlace: verificado o abandonado son ambos finales legítimos y ambos llegan por aquí, discriminados por el output. onError responde a que el subflujo falló, es decir, a que algo se rompió de una manera que el modelo no contemplaba. Confundirlos lleva a modelar como error lo que era un desenlace previsto, y a partir de ahí el padre trata como avería lo que el producto considera normal.

flowchart TD
A[padre en estado verificando] -->|input desde su contexto| B[actor hijo arranca]
B --> C{desenlace}
C -->|final verificado| D[evento done con output]
C -->|final abandonado| D
C -->|excepcion| E[evento error]
D --> F[padre asigna y transiciona]
E --> G[padre va a error tecnico]
style B fill:#89dceb,color:#11111b
style D fill:#a6e3a1,color:#11111b
style E fill:#f38ba8,color:#11111b
💡
La cancelación viene gratis con el estado que invoca

El detalle que más trabajo ahorra de todo el patrón es que el ciclo de vida del hijo está atado al estado que lo invoca. Si el padre abandona verificando por cualquier vía —el evento CANCELAR, un timeout, una transición desde un ancestro— el actor hijo se detiene automáticamente, con todo lo que tuviera abierto dentro. No hay que acordarse de cancelar, no hay banderas de vigencia, no hay respuestas tardías que lleguen cuando ya nadie las espera. Comparado con el equivalente imperativo, donde cada petición en vuelo necesita su propio mecanismo de aborto y su comprobación de si el resultado sigue siendo relevante, la diferencia no es de comodidad sino de clase de bug: aquí la categoría entera de la respuesta obsoleta deja de existir porque la estructura la hace imposible.

Cuándo extraer y cuándo no

Todo compuesto puede convertirse en subflujo, y esa facilidad es un peligro. La extracción tiene un coste real —una frontera más que cruzar, un contrato que mantener, un actor más que observar al depurar— y solo se paga cuando algo lo justifica. Estas señales lo justifican; su ausencia recomienda dejar el compuesto donde está.

♻️

Se usa en varios sitios

El mismo subproceso aparece en dos o tres flujos distintos. Extraerlo elimina duplicación real y da un punto único donde corregir.

🧪

Merece test propio

Tiene bastante lógica interna como para probarse aislado, con su entrada controlada y su salida verificada, sin arrastrar el padre entero.

🧬

Tiene contexto propio

Guarda datos que al padre no le importan. Un contexto separado evita ensuciar el del padre con campos que solo viven durante una fase.

⏱️

Vive y muere aparte

Empieza y acaba dentro de una fase concreta. Su ciclo de vida coincide con un estado, que es justo lo que invoke expresa.

En contra pesan dos situaciones, y la segunda es la que más se equivoca. La primera es el subflujo diminuto: dos estados y una transición no merecen una frontera, y extraerlos añade indirección sin ganar nada. La segunda es la relación que necesita conversación continua: si el padre tiene que enviarle eventos al hijo constantemente y el hijo tiene que informar al padre mientras trabaja, la forma correcta no es la invocación con onDone sino la comunicación entre actores, con envíos explícitos en ambos sentidos. La pista que las separa es cuántas veces se cruza la frontera: si es una vez al empezar y otra al acabar, es un subflujo; si es continuamente, son dos actores conversando y modelarlo como llamada con retorno te obligará a deformar el diseño para que quepa.

Situación Forma correcta Señal que la delata
Fase con principio y fin invoke con input y output Se cruza la frontera dos veces
Diálogo constante Actores con envíos en ambos sentidos Se cruza la frontera todo el rato
Fragmento trivial Compuesto con onDone en el mismo actor No hay contexto ni reuso propios
Trabajo de fondo Actor de larga vida generado aparte Sobrevive al estado que lo lanzó
⚠️
Una máquina invocada que nunca termina es una fuga de diseño

Si extraes un subflujo y resulta que no tiene ningún final alcanzable, has creado algo peor que un compuesto: un actor que consume recursos, que nunca disparará onDone y que el padre solo puede matar abandonando el estado. El síntoma habitual es un padre que reacciona a eventos reenviados por el hijo en lugar de a su terminación, con transiciones que dependen de mensajes intermedios y no del resultado. Antes de extraer cualquier subflujo comprueba que existe al menos un camino hasta un type: 'final' y que ese camino es alcanzable desde cualquier configuración interna razonable; si no lo hay, o el subproceso no era un subflujo, o le falta modelar el desenlace que en tu cabeza dabas por supuesto.

📝
La ruta de refactorización es progresiva y reversible

Merece la pena empezar por el camino barato. Modela primero el subproceso como estado compuesto con su final y su onDone dentro de la misma máquina: cuesta poco, no añade fronteras y ya te da la terminación explícita. Cuando aparezca la primera señal real de extracción —el segundo uso, el test que se vuelve incómodo, el contexto que se ensucia— conviértelo en máquina invocada. La conversión es sorprendentemente indolora porque el padre ya reaccionaba con onDone, y lo único que cambia es de dónde viene el evento sintético y que ahora hay input explícito. Esa continuidad entre las dos formas es una de las mejores propiedades del modelo, y aprovecharla evita tanto la extracción prematura como la máquina monolítica que nadie se atreve a partir.

Un contrato de entrada y salida es lo que hace que un sistema quepa en una cabeza

Lo que este patrón resuelve no es un problema de XState sino el problema central de construir software grande: nadie puede sostener un sistema entero en la cabeza a la vez, y sin embargo hay que construirlo correcto. La única solución conocida —no una de varias, la única— es partirlo en piezas cuyo comportamiento se pueda entender sin abrirlas, y eso exige que cada pieza tenga una frontera con significado: esto entra, esto sale, lo demás es asunto mío. Las funciones llevan haciéndolo desde siempre y por eso podemos escribir programas de un millón de líneas sin conocerlas todas. El problema es que en los sistemas interactivos las piezas no son funciones: tardan, se interrumpen, tienen fases, reciben eventos a mitad, pueden fallar de maneras que no son excepciones. Durante décadas eso significó que no había forma limpia de darles frontera, y por eso el estado de una interfaz compleja acababa siendo una masa donde todo tocaba todo y donde entender una parte exigía entender el resto. El subflujo con input y output recupera la frontera para esa clase de piezas. Una máquina invocada que declara qué recibe y qué devuelve es una unidad de comportamiento con contrato, aunque su ejecución dure cinco minutos, aunque el usuario la cancele a mitad, aunque por dentro tenga catorce estados y tres regiones paralelas. Puedes probarla sola, sustituirla, reescribirla entera sin mirar quién la usa, y razonar sobre su consumidor sin abrirla ni una vez. Eso es exactamente lo que hace un tipo de función bien elegido, trasladado al dominio del tiempo, y es la razón por la que este patrón —y no la jerarquía, ni el paralelismo, ni ninguna de las funcionalidades vistosas— es lo que de verdad permite que un modelo de estados crezca sin colapsar. La lección final del nivel es esa: la terminación no era el tema, era el requisito. Solo lo que termina puede devolver, solo lo que devuelve puede tener contrato, y solo lo que tiene contrato se puede componer sin entenderlo entero.

⚔️ Extrae un subflujo con contrato
  1. Escribe la máquina de verificación con sus dos finales y sus output discriminados, e invócala desde una máquina de alta pasándole el correo con input.
  2. Comprueba en el onDone del padre que llegan ambos desenlaces por la misma transición y que los distingues por el campo del output.
  3. Provoca una excepción dentro del hijo y observa que el padre reacciona por onError; argumenta por qué abandonar no debía llegar por ahí.
  4. Envía CANCELAR al padre estando el hijo a mitad y verifica que el actor hijo se detiene sin necesidad de ninguna limpieza explícita.
  5. Escribe un test del subflujo aislado, con entrada controlada y salida verificada, sin instanciar en ningún momento la máquina padre.
  6. Toma un estado compuesto de una máquina tuya, conviértelo primero en compuesto con final y onDone, y solo después extráelo a máquina invocada; anota qué líneas del padre cambiaron.
  7. Busca en tu código una relación padre e hijo que cruce la frontera constantemente y explica por qué modelarla como invocación con retorno la estaba deformando.