wandres.dev
XSTATE: EFECTOS · invoke y promesas

Ciclo de vida de un actor invocado: nacer al entrar, morir al salir

El ciclo de vida de un actor invocado es la garantía que hace confiable a invoke: se crea en el instante en que la máquina entra en el estado que lo declara y se detiene en el instante en que lo abandona, sin excepción. De esa regla topológica emana la cancelación automática —el AbortSignal de una promesa, el cleanup de un callback, el unsubscribe de un observable— que libera al programador de la tarea más olvidada del asincronismo. Comprender cuándo un actor se reinicia, cuándo persiste y por qué invoke y spawn atan vidas distintas es el cierre del nivel.

⏱ 17 min

Todo el poder de invoke descansa sobre una única regla, y esta lección la lleva hasta el final. Un actor invocado se crea exactamente cuando la máquina entra en el estado que lo declara, y se detiene exactamente cuando lo abandona. No es una recomendación ni un caso frecuente: es una ley del runtime, sin excepciones. De ella se deduce la propiedad que vuelve a invoke digno de confianza —la cancelación automática— y de ella se deduce también todo lo sutil: por qué reentrar en un estado crea un actor nuevo en vez de reanudar el viejo, por qué salir aunque sea un instante mata al actor, y en qué se diferencia esto de spawn, que ata la vida del actor no a un estado sino al propio actor padre. Cerrar el nivel es entender que la gestión de efectos, que en el modelo imperativo es un rompecabezas de banderas y limpiezas manuales, en un statechart es una consecuencia geométrica de por dónde te mueves en el grafo.

🎯 Al terminar esta lección sabrás
  • Fijar la ley del ciclo de vida: creación en la entrada, parada en la salida del estado.
  • Entender la cancelación automática como consecuencia de esa parada.
  • Distinguir reentrada, transición interna y autotransición según reinicien o no el actor.
  • Contrastar invoke (vida atada al estado) con spawn (vida atada al actor padre).

La ley: entrada y salida marcan nacimiento y muerte

Cuando la máquina toma una transición hacia un estado con invoke, el orden es preciso: primero corren las acciones de salida del estado anterior, luego las de entrada del nuevo, y en ese momento el runtime crea e inicia el actor invocado. Mientras la máquina permanezca en ese estado, el actor vive. En cuanto una transición saque a la máquina del estado, antes incluso de completar la entrada al destino, el runtime detiene el actor. La vida del actor es, punto por punto, el intervalo durante el cual el estado está activo.

Esta atadura elimina toda una categoría de bugs por construcción. No existe el caso “el estado cambió pero el efecto sigue corriendo”, porque el efecto ES el estado. No existe “olvidé cancelar”, porque cancelar no es una acción que escribas, sino lo que el runtime hace al salir.

El orden fino importa cuando conviven acciones y actores en una misma transición. XState ejecuta primero las acciones de salida del estado origen, después detiene los actores que ese estado invocaba, luego corre las acciones de la propia transición y por último las de entrada del destino, instante en que nacen sus actores. Esa secuencia determinista garantiza que un actor nunca siga vivo mientras corren las acciones de salida de su estado, y que su sucesor no arranque hasta que el destino esté plenamente entrado.

stateDiagram-v2
[*] --> inactivo
inactivo --> cargando : BUSCAR
cargando --> resultados : onDone
cargando --> inactivo : CANCELAR
cargando : entry crea el actor e inicia la peticion
cargando : exit detiene el actor y aborta la peticion
📝
Un estado final también detiene a sus actores

Alcanzar un estado final de nivel raíz detiene la máquina entera y, con ella, todos sus actores invocados y generados. La muerte de un actor invocado no exige salir hacia un estado hermano: cualquier forma de abandonar el estado que lo hospeda —una transición normal, la parada de un ancestro o la terminación de la máquina— dispara su parada y su limpieza. La ley no tiene rendijas por las que un efecto pueda escaparse vivo.

Cancelación automática: la parada que libera recursos

Detener un actor no es borrarlo de una lista y ya: el runtime invoca el protocolo de parada de su lógica, y cada creador de lógica define qué significa pararse. En fromPromise, pararse dispara el AbortSignal que recibió la función, de modo que un fetch bien escrito se cancela de verdad en la red. En fromCallback, pararse ejecuta la función de limpieza que la lógica retornó —cerrar el socket, quitar el listener—. En fromObservable, pararse cancela la suscripción. La misma orden abstracta, “detente”, se traduce en la cancelación concreta que cada fuente necesita.

import { fromPromise } from 'xstate'

const buscar = fromPromise(
  async ({ input, signal }: { input: { q: string }; signal: AbortSignal }) => {
    // signal se aborta si la maquina sale de cargando antes de resolver
    const res = await fetch(`/api/search?q=${input.q}`, { signal })
    return (await res.json()) as Resultado[]
  },
)

Aquí está el patrón buscador con cancelación resuelto casi sin esfuerzo: si el usuario dispara BUSCAR de nuevo o pulsa CANCELAR mientras una petición está en vuelo, la máquina sale de cargando, el signal se aborta y la petición vieja muere sin llegar a escribir un resultado obsoleto. La condición de carrera clásica del autocompletado —una respuesta lenta que pisa a una rápida más nueva— se desvanece porque la salida del estado ya canceló a la lenta.

Cada lógica traduce la orden abstracta de pararse a la cancelación concreta que su fuente entiende, y conviene tener el mapa a mano.

🎯

fromPromise aborta

Pararse dispara el AbortSignal. Un fetch que lo recibe corta la conexión; un bucle que lo consulta puede rendirse a mitad de un cómputo largo.

🌊

fromObservable desuscribe

Pararse cancela la suscripción al observable, que a su vez ejecuta el teardown que ese flujo tuviera declarado para liberar sus recursos.

🔌

fromCallback limpia

Pararse ejecuta la función que la lógica retornó: cerrar el socket, quitar el listener del DOM, apagar el temporizador que se hubiera abierto.

⚠️
La cancelación solo funciona si la respetas

El runtime aborta el signal, pero es tu código el que debe honrarlo. Un fromPromise que ignora el signal y sigue con su cómputo pesado se detendrá a ojos de la máquina —su resultado se descartará— pero seguirá consumiendo CPU o ancho de banda hasta terminar por su cuenta. Cancelar de verdad exige pasar el signal al fetch, comprobarlo en bucles largos, y limpiar recursos en fromCallback.

Reentrada: por qué salir y volver crea un actor nuevo

Si la ley es “vive mientras el estado está activo”, la pregunta fina es qué cuenta como salir. Una transición externa que abandona el estado y vuelve a entrar —aunque el destino sea el mismo estado— es una salida seguida de una entrada: mata al actor viejo y crea uno nuevo. No hay reanudación; el actor no tiene memoria entre encarnaciones porque son actores distintos con input recalculado.

Frente a eso, una transición interna —una que reacciona a un evento sin salir del estado, típicamente sin target— no toca el ciclo de vida del actor: este sigue vivo, con su identidad intacta. La distinción entre transición externa que reentra y transición interna es, por tanto, la palanca con la que decides si un efecto se reinicia o continúa.

cargando: {
  invoke: { id: 'busqueda', src: 'buscar', input: ({ context }) => ({ q: context.q }) },
  on: {
    // transicion interna: sin target, el actor invocado sigue vivo
    TECLA: { actions: assign({ q: ({ event }) => event.q }) },
    // reentrada externa: reinicia el actor desde cero
    RELANZAR: { target: 'cargando', reenter: true },
  },
}

En XState v5 una transición con target al mismo estado no reentra por defecto: hay que pedirlo con reenter: true. Esa opción es el interruptor explícito entre “continúa el actor” y “reinícialo”, y vuelve visible en el código una decisión que de otro modo quedaría enterrada en la semántica implícita de la transición.

🔄

Reentrada externa

Salir y volver al mismo estado detiene el actor y crea uno nuevo, con input reevaluado. Útil para reiniciar un temporizador o relanzar una petición desde cero.

⏸️

Transición interna

Un evento manejado sin abandonar el estado no afecta al actor: sigue vivo. Útil para actualizar contexto mientras el efecto continúa sin interrupción.

🧹

Salida limpia

Cualquier salida, aunque sea hacia un estado y de vuelta en el mismo instante, ejecuta la parada y su cancelación. No hay salidas silenciosas.

invoke frente a spawn: dos ciclos de vida

invoke no es la única forma de tener actores hijos, y su límite define cuándo usar la alternativa. Un actor invocado no puede sobrevivir al estado que lo declara: esa es su virtud y su restricción. Cuando necesitas un actor cuya vida no encaje en un solo estado —un socket que debe seguir abierto a través de varios estados, o una colección dinámica de actores que nacen y mueren por eventos, no por transiciones—, usas spawn, mediante la acción spawnChild o el helper spawn dentro de assign. Un actor así se guarda en el contexto y vive hasta que lo detienes explícitamente con stopChild o hasta que la máquina raíz para.

import { assign, stopChild } from 'xstate'

// spawn: el actor persiste en el contexto, cruza transiciones
on: {
  ABRIR: {
    actions: assign({
      conexion: ({ spawn }) => spawn('socket', { id: 'ws', input: { url: '/ws' } }),
    }),
  },
  CERRAR: { actions: stopChild('ws') },
}

La regla de decisión es la duración deseada del efecto. ¿Su vida coincide con la de un estado? invoke, y te olvidas de detenerlo. ¿Su vida es independiente de los estados y la gobiernas tú por eventos? spawn, y asumes la responsabilidad de pararlo. Elegir mal —usar spawn para algo que muere con un estado— es reintroducir a mano la fuga que invoke eliminaba de oficio.

Ambos comparten, eso sí, una misma disciplina de persistencia. Cuando serializas una máquina con getPersistedSnapshot, la instantánea incluye el estado de sus actores hijos vivos —invocados o generados—, de modo que al rehidratarla con createActor y la opción snapshot el árbol de actores se reconstruye donde quedó. Un fromPromise a medias no se reanuda —volverá a arrancar al reentrar en su estado—, pero un actor con estado propio, como una máquina hija, sí recupera su configuración. Decidir qué es reanudable y qué no forma parte de pensar bien el ciclo de vida.

ℹ️
Detenerse no es lo mismo que completar

Un actor abandona la escena por dos vías que conviene no confundir: porque completó su trabajo —una promesa que resolvió, una máquina que llegó a un final— o porque fue detenido desde fuera al salir del estado. La primera dispara onDone; la segunda no dispara nada, solo ejecuta la limpieza. Esperar un onDone que en realidad nunca llegará, porque el actor fue cancelado antes de terminar, es un malentendido habitual del ciclo de vida.

La cancelación no se programa: se deduce de la topología del grafo

El cierre de este nivel es la idea más liberadora de todo el modelo de actores en statecharts: la gestión del ciclo de vida de los efectos no es código que escribes, sino una propiedad que emerge de la estructura de tu máquina. En el paradigma imperativo, cada efecto asíncrono arrastra una obligación paralela —una bandera de “montado”, un AbortController que hay que crear y disparar, un removeEventListener que hay que recordar en el punto exacto del desmontaje— y esa contabilidad manual, dispersa y frágil, es la fuente número uno de fugas de memoria, condiciones de carrera y respuestas obsoletas que pisan a las nuevas. invoke disuelve esa clase entera de problemas convirtiendo el ciclo de vida en geometría: el actor existe si y solo si estás en el estado, y como el runtime sabe con precisión cuándo entras y cuándo sales, sabe con la misma precisión cuándo crear y cuándo destruir, incluida toda la cancelación que eso implica. La consecuencia es un cambio de postura mental: dejas de preguntar “¿dónde cancelo esto?” —una pregunta que siempre se responde tarde y en el lugar equivocado— y empiezas a preguntar “¿a qué estado pertenece este efecto?”, cuya respuesta ya contiene la cancelación. El asincronismo, que era el territorio más traicionero de la programación, se vuelve el más disciplinado, porque su corrección ya no depende de tu memoria ni de tu diligencia, sino de una invariante que el grafo hace cumplir por ti. Ese es el sentido último de atar un actor a un estado: no es una comodidad sintáctica, es trasladar la responsabilidad de la cancelación desde el programador falible hacia la topología infalible de la máquina.

⚔️ Domina el ciclo de vida
  1. Construye un buscador con invoke y AbortSignal, dispara BUSCAR dos veces seguidas y confirma en la red que la primera petición se cancela.
  2. Añade CANCELAR en cargando que vuelva a inactivo y verifica que la petición en vuelo se aborta al salir.
  3. Provoca una reentrada externa al mismo estado y demuestra, con un log en la lógica, que nace un actor nuevo con input reevaluado.
  4. Convierte esa misma transición en interna (sin target) y comprueba que ahora el actor sobrevive intacto.
  5. Reescribe un socket compartido primero con invoke en un estado y luego con spawn en el contexto; razona qué cambia en su vida.
  6. Enumera tres bugs del modelo imperativo —fuga, carrera, respuesta obsoleta— y explica por qué la ley de ciclo de vida los previene por construcción.