Skip to content

Comments

feat(core): add event property to machine snapshots#5445

Open
cevr wants to merge 2 commits intostatelyai:mainfrom
cevr:feat/snapshot-event
Open

feat(core): add event property to machine snapshots#5445
cevr wants to merge 2 commits intostatelyai:mainfrom
cevr:feat/snapshot-event

Conversation

@cevr
Copy link
Contributor

@cevr cevr commented Jan 13, 2026

Summary

This PR adds an event property to machine snapshots that tracks the event that triggered the current state. This enables deriving ephemeral state from events without needing to explicitly store event data in context.

Rationale

Currently, if you need to access event data in your UI or derive state from the triggering event, you must explicitly store it in context:

// Before: Must store validation errors in context (bloat for ephemeral data)
const machine = createMachine({
  context: { validationErrors: null },
  initial: 'editing',
  states: {
    editing: {
      on: {
        SUBMIT: [
          { guard: 'isValid', target: 'submitting' },
          { 
            target: 'editing',
            actions: assign({ 
              validationErrors: ({ event }) => event.errors 
            })
          }
        ]
      }
    },
    submitting: { /* ... */ }
  }
});

// In component
const errors = snapshot.context.validationErrors;

With this change, ephemeral data can be derived directly from the event:

// After: Derive validation errors from event (no context bloat)
const machine = createMachine({
  initial: 'editing',
  states: {
    editing: {
      on: {
        SUBMIT: [
          { guard: 'isValid', target: 'submitting' },
          { target: 'invalid' }
        ]
      }
    },
    invalid: {
      on: {
        SUBMIT: [
          { guard: 'isValid', target: 'submitting' },
          { target: 'invalid' }
        ]
      }
    },
    submitting: { /* ... */ }
  }
});

// In component - derive errors from event when in invalid state
const errors = snapshot.matches('invalid') 
  ? snapshot.event.errors 
  : null;

This is particularly useful for:

  • Ephemeral UI state: Form validation errors, animation triggers, toast messages
  • Debugging: Easily see what event caused the current state
  • Derived state: Compute values based on the triggering event without context bloat

Key Changes

  • Add event property to MachineSnapshotBase interface
  • Add event to StateConfig interface
  • Update createMachineSnapshot() to include event from config
  • Update getPreInitialState() to set init event on initial snapshot
  • Update macrostep() to track the triggering event
  • Event is preserved through eventless (always) transitions
  • Event is included in serialized/persisted snapshots

Structural Sharing

To preserve snapshot identity when the same event is sent repeatedly (and no transition occurs), this PR implements structural sharing adapted from TanStack Query. This means:

actor.send({ type: 'PING', data: { value: 42 } });
const snapshot1 = actor.getSnapshot();

actor.send({ type: 'PING', data: { value: 42 } }); // Deeply equal event
const snapshot2 = actor.getSnapshot();

snapshot1 === snapshot2; // true (same reference due to structural sharing)

Behavior

  • Initial snapshot: Has { type: 'xstate.init', input } as the event
  • After transition: Snapshot's event is updated to the triggering event
  • Eventless transitions: Previous event is preserved (not replaced with a marker)
  • Serialization: Event is included in toJSON() and getPersistedSnapshot()

Test plan

  • Event is present on snapshot after transition
  • Event updates correctly on each transition
  • Initial snapshot has init event with input
  • Event is preserved on eventless (always) transitions
  • Structural sharing works for deeply equal events
  • Different nested values create new snapshots
  • Event is included in persisted snapshot
  • Event is restored from persisted snapshot
  • All existing tests pass
  • TypeScript types are correct

🤖 Generated with Claude Code

This adds an `event` property to machine snapshots that tracks the event
that triggered the current state. This enables deriving ephemeral state
from events without needing to explicitly store event data in context.

Key changes:
- Add `event` property to `MachineSnapshotBase` interface
- Add `event` to `StateConfig` interface
- Update `createMachineSnapshot()` to include event from config
- Update `getPreInitialState()` to set init event on initial snapshot
- Update `macrostep()` to track the triggering event
- Event is preserved through eventless (always) transitions
- Event is included in serialized/persisted snapshots
- Uses structural sharing (adapted from TanStack Query) to preserve
  snapshot identity when deeply equal events are sent

The initial snapshot has the init event (`{ type: 'xstate.init', input }`).
When an event is sent, the snapshot's event property is updated.
Eventless transitions preserve the previous event.

Co-Authored-By: Claude <noreply@anthropic.com>
@changeset-bot
Copy link

changeset-bot bot commented Jan 13, 2026

⚠️ No Changeset found

Latest commit: 64313b4

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Update the stringifyState function to also apply the custom serializer
to the event property, since snapshots now include the triggering event.

Co-Authored-By: Claude <noreply@anthropic.com>
@davidkpiano
Copy link
Member

IIRC @Andarist and I decided against keeping .event in state because it didn't feel like something that belonged in state, unless explicitly provided by the developer.

For transient data, enq.emit(…) would be best suited for that IMO.

@cevr
Copy link
Contributor Author

cevr commented Jan 13, 2026

i suppose if you're thinking about purity its true that it doesnt belong in a snapshot. practically speaking, it just makes things a bit more ergonomic.

after using this library extensively the past few years, i can say there's thousands of little cuts that make it a slog sometimes, but overall its the best there is

@davidkpiano
Copy link
Member

i suppose if you're thinking about purity its true that it doesnt belong in a snapshot. practically speaking, it just makes things a bit more ergonomic.

after using this library extensively the past few years, i can say there's thousands of little cuts that make it a slog sometimes, but overall its the best there is

Also note that it gets even more ambiguous when an event can raise event(s) and/or go through transient (eventless/always) transitions, making it unclear what the snapshot.event should be - the initial or most recent event? (both are useful)

Another reason we didn't add it is because we want to maintain stateA === stateB if the event does not cause a state transition, but stateA.event !== stateB.event which negates this.


Can you give me a practical example of where having .event on the state would help?

@cevr
Copy link
Contributor Author

cevr commented Jan 14, 2026

for example I have a list of elements that each have an action to delete. TapDelete transitions to Deleting State.

if I want to track which button exactly should show a loading spinner, I need to add the id to context, or I can do snapshot.event.type === 'tap_remove' ? snapshot.event.id : undefined and in the loading state do loading={state === Deleting && removalId === id}

there's a bit of a load when adding context - you need to update types, add actions, hook up actions, add param, clear state afterwards etc. but with events you can derive it with a single line of code with no cleanup code or setup code needed

@cevr
Copy link
Contributor Author

cevr commented Jan 14, 2026

Status Quo (context-based):

// Must add to types, context, actions, wire up params, AND remember cleanup
const listMachine = setup({
  types: { context: {} as { items: Item[]; deletingId: string | null } },
  actions: {
    setDeletingId: assign({ deletingId: (_, params: { id: string }) => params.id }),
    clearDeletingId: assign({ deletingId: () => null }),
  },
}).createMachine({
  context: { items: [], deletingId: null },
  states: {
    idle: {
      on: {
        TAP_DELETE: {
          target: 'deleting',
          actions: [{ type: 'setDeletingId', params: ({ event }) => ({ id: event.id }) }],
        },
      },
    },
    deleting: {
      invoke: {
        onDone: { target: 'idle', actions: ['clearDeletingId'] },
        onError: { target: 'idle', actions: ['clearDeletingId'] }, // easy to forget
      },
    },
  },
});

// Usage
function ListItem({ id, actorRef }: { id: string; actorRef: ActorRefFrom<typeof listMachine> }) {
  const isDeleting = useSelector(
    actorRef,
    (snap) => snap.matches('deleting') && snap.context.deletingId === id
  );
  return <button>{isDeleting ? <Spinner /> : 'Delete'}</button>;
}

With event in snapshot:

// No extra context, no actions, no cleanup
const listMachine = setup({
  types: { context: {} as { items: Item[] } },
}).createMachine({
  context: { items: [] },
  states: {
    idle: { on: { TAP_DELETE: 'deleting' } },
    deleting: { invoke: { onDone: 'idle', onError: 'idle' } },
  },
});

// Usage - single line derivation, automatically "clears" when next event arrives
function ListItem({ id, actorRef }: { id: string; actorRef: ActorRefFrom<typeof listMachine> }) {
  const isDeleting = useSelector(
    actorRef,
    (snap) => snap.matches('deleting') && snap.event.type === 'TAP_DELETE' && snap.event.id === id
  );
  return <button>{isDeleting ? <Spinner /> : 'Delete'}</button>;
}

The context approach requires: updating types, initializing state, defining 2 actions, wiring params, and remembering cleanup in every exit path. The event approach is zero setup and automatic cleanup.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants