| title | Internal events |
|---|---|
| description | Declare events that the machine may raise but no one may send in. |
Declare private event payload schemas in schemas.internalEvents. These events
are part of the machine's internal event union, but are excluded from the
public actor.send(...) and actor.trigger protocols.
const machine = createMachine({
schemas: {
events: {
start: z.object({})
},
internalEvents: {
tick: z.object({ count: z.number() })
}
},
initial: 'idle',
states: {
idle: {
on: {
start: (_, enq) => {
enq.raise({ type: 'tick', count: 1 });
},
tick: { target: 'done' }
}
},
done: {}
}
});Sending start raises tick internally and the machine transitions to done. Sending tick directly rejects the event at the delivery boundary:
const actor = createActor(machine, {
onRejectedEvent: (rejection) => {
rejection.event; // { type: 'tick' }
rejection.reason; // 'internalEvent'
}
}).start();
actor.send({ type: 'tick' });
// The event is not delivered. `actor.send` does not throw and the actor
// does not error; its state is unchanged.A rejected event never enters the machine. The rejection is reported two ways:
- The
onRejectedEventdead-letter hook oncreateActoroptions receives anEventRejectionobject with theevent,targetId,sourceRef,eventOrigin('external'or'actor'),reasonanderror. - In development mode, a console warning describes the rejection.
Internal events are still ordinary events in every other respect. They appear
in on handlers and are raised with enq.raise like any other event. They do
not need a duplicate entry in schemas.events.
Entries may use a .-segment wildcard, which covers every event type under that prefix.
schemas: {
internalEvents: {
'change.*': z.object({ value: z.string() })
}
}With that in place, change.value, change.status and any other change.* event can be raised internally but is rejected from outside.
The check compares the sender to the receiver. An internally executed
self-targeted effect such as enq.raise(...) or enq.sendTo(self, ...) is
allowed. Anything else is rejected: actor.send from application code, a
parent sending to a child, or a sibling actor.
Warning: a rejection is silent unless you observe it. It does not throw and it does not error the actor. Register
onRejectedEventoractor.system.onRejectedEvent(...)to detect rejected events. Dead letters are not inspection events. Treat internal events as a private surface, and if application code needs to reach that behavior, expose a public event that raises the internal one.
Use internal events for:
- a
tickthat only the machine's own timer logic should produce change.*events raised by a form's own validation pass, so no caller can fake a field update- upload progress events raised by an invoked actor's callbacks, where an outside
progressevent would corrupt the state
The keys in schemas.internalEvents narrow the actor's public protocol: those
types, and types matched by a wildcard, are removed from what actor.send and
actor.trigger accept, so an invalid send is a type error rather than a
runtime rejection. They remain fully typed inside the machine for on
handlers, guards, actions, enq.raise and transitions.
The top-level internalEvents: ['tick'] list was removed before 6.0. Move
each listed event's schema from schemas.events to schemas.internalEvents.
A machine config with a top-level internalEvents key throws an error naming
schemas.internalEvents.
createMachine({
schemas: {
events: {
start: z.object({})
},
internalEvents: {
tick: z.object({}),
'change.*': z.object({ value: z.string() })
}
},
initial: 'idle',
states: {
idle: {
on: {
start: (_, enq) => {
enq.raise({ type: 'tick' });
},
tick: { target: 'done' }
}
},
done: {}
}
});eventSchema(machine) is a Standard Schema for complete public input event
objects. Validate untrusted input (including agent tool arguments) before sending:
const result = await eventSchema(machine)['~standard'].validate(input);
if (result.issues) {
throw new Error(result.issues[0]?.message);
}
actor.send(result.value);The schema validates payloads using schemas.events, rejects declared internal
events (including wildcard descriptors), and rejects xstate.* / @xstate.*
runtime events. xstate.route is accepted only with a to destination identifying
an explicitly configured route. Route guards and current-state eligibility are
evaluated during delivery, not schema validation.
Without event schemas, application event names remain open. types<T>() provides
type inference only; use a runtime schema such as Zod to validate payloads.
Validation is not authorization: a well-formed public event may still require
application permission checks.
actor.send(event) keeps supporting trusted runtime delivery and replay. It does
not automatically run eventSchema(machine). For event histories,
machineVersions().adaptEvents() retains historical internal/runtime validation
when given actual machines.