Comunicación entre actores: el paso de mensajes
Los actores son islas y el mensaje es el único puente. XState v5 ofrece cuatro caminos para tenderlo: sendTo envía un evento a un actor identificado por su referencia o su id; sendParent responde al actor que te invocó; pasar referencias por input hace explícita la relación en vez de asumir un padre implícito; y emit junto con actor.on abre un canal de salida hacia suscriptores externos al sistema. Esta lección explica la semántica asíncrona del paso de mensajes, cuándo usar cada camino y por qué la referencia de un actor es una dirección, no un puntero a su memoria.
Si los actores no comparten memoria, toda coordinación entre ellos tiene que viajar por un solo carril: el mensaje. En el modelo de Hewitt no hay atajos —ni una variable global que ambos leen, ni una llamada directa a un método ajeno—, y esa pobreza aparente es justo lo que mantiene el sistema seguro. XState v5 te da un puñado de formas de enviar mensajes, cada una para una relación distinta entre emisor y receptor. Dominarlas es aprender a dibujar las conversaciones de tu sistema con precisión, sin abrir jamás una grieta en el aislamiento.
- Enviar eventos a un actor con
sendTo, por referencia o porid. - Responder al actor invocador con
sendParenty entender sus límites. - Pasar referencias de actor por
inputcomo alternativa explícita al padre implícito. - Abrir un canal de salida hacia el exterior con
emityactor.on.
sendTo: enviar a una referencia
sendTo es la herramienta central de la comunicación. Es una acción que deposita un evento en el buzón de otro actor. Le indicas el destino de dos maneras: con la referencia que guardaste al crear el hijo con spawn, o con el id bajo el que lo lanzaste. El evento es un objeto plano y serializable con un campo type, nunca una función ni una referencia a memoria compartida.
import { sendTo } from "xstate"
// Por referencia: usas el ActorRef guardado en el context.
const porReferencia = sendTo(
({ context }) => context.hijo,
{ type: "SALUDA" },
)
// Por id: util cuando lanzaste al hijo con spawnChild y un id conocido.
const porId = sendTo("carga1", { type: "REINTENTA" })
// El evento puede construirse a partir del contexto y del evento entrante.
const conDatos = sendTo(
({ context }) => context.hijo,
({ context }) => ({ type: "CONFIGURA", limite: context.limite }),
)
El envío es asíncrono por definición: sendTo no espera a que el destinatario procese el evento ni recibe un valor de vuelta. Deja el mensaje en el buzón y sigue. Si necesitas una respuesta, el destinatario tendrá que enviarte a ti otro mensaje —el mismo patrón de dirección de respuesta que viste en el pseudocódigo de la primera lección—. Esta asincronía no es un defecto que haya que sortear: es lo que impide que dos actores se bloqueen mutuamente esperándose, el clásico interbloqueo del mundo de los candados.
flowchart LR A[actor A] -- pregunta con su direccion --> B[actor B] B -. responde a esa direccion .-> A style A fill:#89b4fa,color:#11111b style B fill:#a6e3a1,color:#11111b
sendParent y el patrón de respuesta
Cuando un actor hijo quiere hablarle a quien lo creó, la vía directa es sendParent: envía un evento al buzón del padre sin necesitar su referencia explícita. Es cómodo para el caso clásico en que un hijo termina una tarea y avisa hacia arriba.
import { sendParent } from "xstate"
// Dentro de la maquina hija: avisa al padre de que ha terminado.
const hijo = createMachine({
initial: "trabajando",
states: {
trabajando: {
on: { LISTO: { actions: sendParent({ type: "HIJO_TERMINO" }) } },
},
},
})
sendParent tiene una debilidad: acopla al hijo con la suposición de que siempre tendrá un padre que entiende el evento HIJO_TERMINO. Reutilizar ese hijo bajo otro padre, o como actor raíz, rompe la suposición en silencio. Por eso la comunidad de XState en 2026 prefiere, para lógica reutilizable, la alternativa explícita: pasar la referencia del destinatario por input.
Referencias por input: la relación explícita
En lugar de asumir un padre implícito, el padre puede entregarle al hijo su propia dirección en el momento de crearlo, usando self e input. El hijo recibe esa referencia como un dato de configuración y le responde con sendTo, exactamente igual que a cualquier otro actor. La relación deja de ser un supuesto y pasa a estar escrita en el código.
import { assign } from "xstate"
// El padre pasa SU PROPIA referencia al hijo por input.
const crear = assign({
hijo: ({ spawn, self }) =>
spawn("trabajador", { input: { volverA: self } }),
})
// El hijo lee input.volverA y le responde con sendTo, no con sendParent.
const responder = sendTo(
({ context }) => context.volverA,
{ type: "HIJO_TERMINO" },
)
La preferencia por input sobre sendParent es la misma lección que atraviesa todo el track del estado: hacer las dependencias explícitas gana a asumirlas. Un hijo que recibe por input la dirección a la que responder no sabe ni le importa si esa dirección es su padre, un abuelo o un actor hermano; es reutilizable y comprobable en aislamiento. Un hijo que usa sendParent lleva grabada la suposición de un padre concreto, y esa suposición es una dependencia oculta que estalla al recolocarlo. sendParent sigue siendo válido para casos locales y desechables; input es la elección de quien diseña actores para durar.
Puestas todas las piezas juntas, un padre y un hijo se comunican en las dos direcciones sin compartir un solo byte de memoria: el padre crea al hijo pasándole su dirección por input, le envía trabajo con sendTo, y el hijo devuelve el resultado a esa dirección.
import { setup, assign, sendTo } from "xstate"
const hijo = createMachine({
context: ({ input }) => ({ volverA: input.volverA }),
on: {
SUMA: {
actions: sendTo(
({ context }) => context.volverA,
({ event }) => ({ type: "RESULTADO", n: event.a + event.b }),
),
},
},
})
const padre = setup({ actors: { hijo } }).createMachine({
context: {},
entry: assign({
calc: ({ spawn, self }) => spawn("hijo", { input: { volverA: self } }),
}),
on: {
PEDIR: { actions: sendTo(({ context }) => context.calc, { type: "SUMA", a: 2, b: 3 }) },
RESULTADO: { actions: ({ event }) => console.log("me respondio:", event.n) },
},
})
emit: hablar hacia fuera del sistema
Las tres vías anteriores comunican actores entre sí, dentro del sistema. Pero a menudo necesitas que el mundo exterior —un componente de UI, un servicio de analítica— reaccione a algo que ocurre dentro de un actor, sin acoplar ese actor a la UI. Para eso está emit: una acción que lanza un evento hacia fuera, que cualquiera suscrito con actor.on puede escuchar. Es la contraparte de salida de subscribe.
import { emit } from "xstate"
// Dentro de la maquina: emite un evento hacia el exterior.
const guardar = emit({ type: "notificacion", texto: "cambios guardados" })
// Fuera, en la UI: te suscribes a ese tipo de evento emitido.
actor.on("notificacion", (evento) => {
mostrarToast(evento.texto)
})
La diferencia con subscribe es de granularidad y de propósito. subscribe te entrega cada snapshot completa del actor —su estado íntegro tras cada cambio—; actor.on te entrega solo los eventos puntuales que el actor decidió emitir, como avisos discretos. Uno es para reflejar estado; el otro, para señalar acontecimientos. Confundirlos lleva a UIs que se redibujan de más o a notificaciones que se pierden.
| Vía | Del emisor al receptor | Cuándo usarla |
|---|---|---|
sendTo |
A un actor por referencia o id |
El caso general entre actores |
sendParent |
De un hijo a su padre implícito | Avisos locales y desechables |
input con self |
Referencia entregada al crear | Actores reutilizables y comprobables |
emit con actor.on |
De un actor al exterior del sistema | Señalar acontecimientos a la UI |
El error mental más costoso al llegar a los actores es confundir una referencia de actor con un puntero a un objeto. Un puntero te da acceso a la memoria: con él lees campos y llamas a métodos. Una referencia de actor te da exactamente una cosa: la capacidad de dejar un mensaje en un buzón. No puedes leer el estado del actor a través de su referencia, no puedes invocar sus métodos, no puedes tocar su context. Solo puedes enviarle un evento y esperar. Esta pobreza deliberada es la que hace honor al primer axioma de Hewitt —influir en otro es únicamente enviarle un mensaje a una dirección que conoces— y es la que sostiene todas las garantías del modelo. Por eso sendTo, sendParent, input y emit no son cuatro APIs arbitrarias, sino cuatro respuestas a una sola pregunta: ¿de dónde saca el emisor la dirección del receptor? De una referencia que guardó al crearlo, del vínculo implícito con su padre, de un dato que le entregaron al nacer, o de una suscripción abierta desde fuera. Cambia la procedencia de la dirección y cambias la topología de las conversaciones de tu sistema; pero el acto en sí —depositar un mensaje asíncrono en un buzón ajeno y no tocar nada más— es siempre el mismo, y es lo único que un actor puede hacerle a otro. Diseñar con actores es, literalmente, decidir quién conoce la dirección de quién.
- Crea un padre que engendre un hijo con
spawn, guarde su referencia y le envíe un evento consendTopor referencia. - Repite el envío usando el
iddel hijo en lugar de su referencia y comprueba que llega igual. - Haz que el hijo avise de que terminó, primero con
sendParenty luego pasándole la referencia del padre porinputy usandosendTo; compara el acoplamiento de cada versión. - Emite un evento con
emitdesde una transición y escúchalo desde fuera conactor.on; contrasta lo que recibes con lo que te daríasubscribe. - Rellena una fila más de la tabla de vías con un quinto caso: dos actores hermanos que necesitan hablarse sin pasar por el padre.
- Explica por qué
sendTono puede devolver un valor y cómo se implementa entonces una petición con respuesta entre dos actores.