Stately
XState v6 alpha

Internal events

Declare events that the machine may raise but no one may send in.

XState v6 is in alpha

APIs and behavior may change before the stable release.

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 onRejectedEvent dead-letter hook on createActor options receives an EventRejection object with the event, targetId, sourceRef, eventOrigin ('external' or 'actor'), reason and error.
  • 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 onRejectedEvent or 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 tick that 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 progress event 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: {}
  }
});

On this page