wandres.dev
XSTATE: EFECTOS · invoke y promesas

invoke: un actor atado a la vida de un estado

invoke es la respuesta de XState a la pregunta más difícil de toda máquina de estado: qué hacer con lo asíncrono. En lugar de esparcir peticiones, temporizadores y sockets por acciones sueltas que nadie sabe cuándo limpiar, invoke ata un actor a un estado concreto: el efecto nace cuando la máquina entra en ese estado y muere cuando lo abandona. El estado deja de describir solo una situación y pasa a describir un proceso vivo, gobernado por el runtime como un ciudadano de primera clase del sistema de actores.

⏱ 16 min

Una máquina de estado describe con precisión situaciones discretas y las transiciones válidas entre ellas. Pero una aplicación real no vive solo en estados: vive en el tiempo, lanzando peticiones que tardan, abriendo sockets que emiten, arrancando temporizadores que laten. El instinto ingenuo es colocar esos efectos en acciones de entrada y confiar en acordarse de cancelarlos al salir. Ese olvido es la fuente inagotable de fugas: peticiones que resuelven sobre un estado que ya no existe, sockets que siguen abiertos, timers que disparan en el vacío. La tesis de invoke es quirúrgica: un efecto que pertenece a un estado debe nacer al entrar en él y morir al salir, y de esa contabilidad se encarga la máquina, no tú. Al hacerlo, invoke convierte lo asíncrono en un actor: una unidad con identidad, buzón y ciclo de vida, subordinada al estado que la invoca.

🎯 Al terminar esta lección sabrás
  • Entender por qué un efecto asíncrono debe atarse a un estado y no a una acción suelta.
  • Leer la anatomía de un invoke: src, id, input, onDone, onError.
  • Ver el actor invocado como un ciudadano del sistema, con identidad y buzón.
  • Distinguir invoke de spawn según a qué se ata el ciclo de vida del actor.

El efecto que pertenece a un estado

Piensa en un estado llamado cargando. Su nombre no describe una configuración estática: describe algo que está ocurriendo. Mientras la máquina permanece en cargando, hay una petición en vuelo; cuando la petición termina, la máquina ya no debería estar en cargando. El estado y el efecto son el mismo hecho visto desde dos ángulos —uno declarativo, otro operacional—. invoke hace literal esa identidad: declara que, por definición, estar en cargando ES tener corriendo cierto actor.

Esta atadura resuelve de raíz el problema del efecto huérfano. En el modelo de acciones sueltas, el efecto y el estado tienen ciclos de vida independientes y hay que sincronizarlos a mano. Con invoke, el ciclo de vida del efecto ES el del estado: no hay nada que sincronizar porque no hay dos cosas, hay una. La cancelación deja de ser una tarea que puedes olvidar y pasa a ser una consecuencia estructural de salir del estado.

stateDiagram-v2
[*] --> inactivo
inactivo --> cargando : FETCH
cargando --> exito : onDone
cargando --> fallo : onError
fallo --> cargando : REINTENTAR
cargando : entry nace el actor invocado
cargando : exit muere el actor invocado

Anatomía de un invoke

Un invoke es un objeto declarado dentro de un estado. En XState v5 lo habitual es registrar la lógica del actor en setup bajo actors y referenciarla por nombre desde src, lo que mantiene la máquina serializable y tipada.

import { setup, fromPromise, assign } from 'xstate'

const maquina = setup({
  types: {
    context: {} as { userId: string; usuario: Usuario | null; error: unknown },
    events: {} as { type: 'FETCH'; userId: string } | { type: 'REINTENTAR' },
  },
  actors: {
    cargarUsuario: fromPromise(
      async ({ input }: { input: { userId: string } }) => {
        const res = await fetch(`/api/users/${input.userId}`)
        if (!res.ok) throw new Error(`HTTP ${res.status}`)
        return (await res.json()) as Usuario
      },
    ),
  },
}).createMachine({
  id: 'perfil',
  initial: 'inactivo',
  context: { userId: '', usuario: null, error: null },
  states: {
    inactivo: {
      on: {
        FETCH: {
          target: 'cargando',
          actions: assign({ userId: ({ event }) => event.userId }),
        },
      },
    },
    cargando: {
      invoke: {
        id: 'cargarUsuario',
        src: 'cargarUsuario',
        input: ({ context }) => ({ userId: context.userId }),
        onDone: {
          target: 'exito',
          actions: assign({ usuario: ({ event }) => event.output }),
        },
        onError: {
          target: 'fallo',
          actions: assign({ error: ({ event }) => event.error }),
        },
      },
    },
    exito: { type: 'final' },
    fallo: { on: { REINTENTAR: 'cargando' } },
  },
})

Cada campo tiene un papel exacto. src nombra la lógica del actor a ejecutar; id le da una identidad estable dentro de la máquina, indispensable para dirigirle eventos o leer su instantánea; input calcula el argumento con el que nace el actor a partir del contexto y del evento; onDone y onError son transiciones que se disparan cuando el actor termina bien o falla. Sin id, XState deriva uno del src, pero declararlo explícitamente es la disciplina profesional.

💡
src puede ser una cadena o lógica en línea

Referenciar por cadena (src: 'cargarUsuario') desacopla la máquina de su implementación: puedes sustituir el actor real por un doble en los tests con machine.provide. Poner la lógica en línea (src: fromPromise(...)) es más directo para prototipos, pero acopla la definición y estorba a las pruebas. En producción, casi siempre conviene la referencia por nombre.

El actor invocado es un ciudadano del sistema

Un actor invocado no es un callback anónimo: es una entidad con identidad propia dentro del sistema de actores de la máquina. Tiene su propia instantánea consultable, puede recibir eventos que le dirijas con sendTo y, según su lógica, puede devolverte eventos. Por eso hablamos de actor y no de mera promesa: la promesa es solo una de las lógicas posibles con las que puede animarse.

🆔

Identidad

El id ancla al actor en el sistema. Con él puedes referenciarlo desde otras acciones, dirigirle eventos o leer su estado mientras vive.

📥

Buzón

Un actor invocado puede recibir eventos del padre y, en el caso de fromCallback o de máquinas hijas, enviar eventos de vuelta. La comunicación es bidireccional.

Ciclo de vida

Nace en la entrada al estado y se detiene en la salida. El runtime lleva esa contabilidad; tú solo declaras la relación.

Referenciar al actor por su id habilita el diálogo en vivo. Desde una transición del padre puedes dirigirle un evento con sendTo, y desde una acción puedes leer su instantánea con getSnapshot para decidir según su progreso. Esa es la diferencia tangible entre un actor invocado y una promesa lanzada al vuelo: no es una caja cerrada que solo entrega un valor al terminar, sino un interlocutor con el que se conversa mientras vive.

import { sendTo } from 'xstate'

cargando: {
  invoke: {
    id: 'carga',
    src: 'cargarUsuario',
    input: ({ context }) => ({ userId: context.userId }),
  },
  on: {
    ABORTAR: { actions: sendTo('carga', { type: 'CANCELAR' }) },
  },
}

invoke frente a spawn

XState ofrece dos maneras de crear actores hijos, y la diferencia es precisamente a qué atan el ciclo de vida. invoke ata el actor a un estado: mientras el estado está activo el actor vive, y al salir muere. spawn (a través de la acción spawnChild o del helper spawn dentro de assign) ata el actor al contexto: el actor persiste a través de transiciones y solo se detiene cuando tú lo ordenas con stopChild o cuando la máquina entera para.

La regla práctica se deduce de la pregunta “¿cuánto debe vivir este efecto?”. Si su vida coincide con la de un estado —una petición que solo tiene sentido mientras cargas—, usa invoke. Si debe sobrevivir a varios estados —un socket compartido, una lista dinámica de tareas de fondo—, usa spawn. Volveremos a esta dicotomía con detalle al estudiar el ciclo de vida, pero conviene grabarla ya: invoke es declarativo y ligado al estado; spawn es imperativo y ligado al actor.

flowchart LR
subgraph invoke
  direction LR
  E1[entra al estado] --> V1[actor vive] --> S1[sale del estado] --> M1[actor muere]
end
subgraph spawn
  direction LR
  E2[un evento lo crea] --> V2[vive cruzando estados] --> S2[stopChild lo detiene]
end
style V1 fill:#a6e3a1,color:#11111b
style V2 fill:#fab387,color:#11111b
ℹ️
Varios invoke en un mismo estado

Un estado puede invocar más de un actor a la vez pasando un array a invoke. Todos nacen al entrar y todos mueren al salir, en paralelo. Es el patrón para arrancar simultáneamente, por ejemplo, una petición de datos y un temporizador de expiración: dos actores independientes cuya vida comparte exactamente la misma ventana.

Un estado no es una foto, es un proceso con principio y fin

El salto conceptual de este nivel es dejar de ver el estado de una máquina como una etiqueta pasiva —un valor de un enum— y empezar a verlo como la delimitación de un proceso vivo en el tiempo. invoke es la construcción que materializa esa idea: al declarar un actor dentro de un estado, estás afirmando que ese estado no describe una situación quieta, sino la ejecución de algo que empezó al entrar y terminará al salir. Ahí está la elegancia profunda de los statecharts frente a las máquinas de estado clásicas: la FSM académica solo modela transiciones entre puntos, pero un statechart con invoke modela también qué ocurre DENTRO de cada punto mientras lo habitas. La cancelación deja de ser una preocupación —esa cosa que se te olvida y provoca fugas— para convertirse en una garantía que emana de la topología: si sales del estado, el actor se para, sin excepción, porque su existencia estaba definida por tu permanencia allí. Cuando interiorizas esto, dejas de escribir efectos que hay que recordar apagar y empiezas a diseñar estados que ya llevan su apagado incorporado. El asincronismo, que en el modelo imperativo es la fuente número uno de bugs, se vuelve la parte más disciplinada de tu sistema, porque cada efecto tiene un dueño explícito —el estado— y ese dueño responde por su nacimiento y su muerte.

⚔️ Ata tu primer efecto a un estado
  1. Escribe una máquina con estados inactivo, cargando, exito y fallo que invoque un fromPromise real contra una API pública.
  2. Añade un botón REINTENTAR en fallo que vuelva a cargando y comprueba que se crea un actor nuevo, no se reutiliza el anterior.
  3. Registra la lógica del actor en setup.actors y luego sustitúyela en un test con machine.provide; verifica que la máquina no cambió.
  4. Declara un segundo invoke en cargando —un temporizador— pasando un array, y observa que ambos actores comparten la ventana de vida del estado.
  5. Explica en dos frases por qué colocar el fetch en una acción de entrada, en vez de en un invoke, abriría la puerta a una fuga.
  6. Dibuja el diagrama de estados marcando en qué transición nace y en cuál muere el actor invocado.