Internal events
Declare events that the machine may raise but no one may send in.
XState v6 is in alpha
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.
Wildcard patterns
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.
What counts as "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
onRejectedEventor an inspection observer to detect rejected 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
TypeScript
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.
In development builds, a machine config with a top-level internalEvents key
throws an error naming schemas.internalEvents.
Internal events cheatsheet
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: {}
}
});