feat(core): add event property to machine snapshots#5445
feat(core): add event property to machine snapshots#5445cevr wants to merge 2 commits intostatelyai:mainfrom
Conversation
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>
|
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>
|
IIRC @Andarist and I decided against keeping For transient data, |
|
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 Another reason we didn't add it is because we want to maintain Can you give me a practical example of where having |
|
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 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 |
|
⏺ 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. |
Summary
This PR adds an
eventproperty 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:
With this change, ephemeral data can be derived directly from the event:
This is particularly useful for:
Key Changes
eventproperty toMachineSnapshotBaseinterfaceeventtoStateConfiginterfacecreateMachineSnapshot()to include event from configgetPreInitialState()to set init event on initial snapshotmacrostep()to track the triggering eventStructural 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:
Behavior
{ type: 'xstate.init', input }as the eventtoJSON()andgetPersistedSnapshot()Test plan
🤖 Generated with Claude Code