Feature request / question (unsure if this feature is supported, seems like it isn't from what I've tried so far)
My use case: I'd like to run an async action that updates the state machine's context upon completion, and I'd like to do this every time a certain state is exited (onExit).
Why:
invoke calls a service when a state is entered. Say I have states 'default', 'one', 'two', and 'three'; I want to invoke 'saveToDb' (which is asynchronous) when transitioning from 'default' to either 'one', 'two', or 'three'. It'd be nice if I could invoke that service upon exiting the 'default' state, instead of repeating the same invoke code three times for my other states.javascript
{
...otherStateConfigHere,
onExit: (ctx, event) => {
saveToDb({ name: event.name }).then( (result) => {
console.log("Finished saving to db, new id: ", result);
actions.assign({ newId: result });
}
}
}
If there is already a nice way to handle this use case, please let me know! I'm still learning my way around xstate so I figure it's likely I just missed something.
onExit: {...invokeConfig} so onExit can take an invoke config object, not just an action/array of actions. Maybe extending this to work with any transition would be nice too?(No proof of concept / code sandbox made for this yet, sorry! Let me know if making one would help clarify this question/feature request!)
Great question - this is one of those tricky cases where the _design_ of the machine is the most important part.
First of all, all the action creators are pure functions; they don't actually execute side-effects, so actions.assign({ newId: result }); in your above example would have no effect.
Actions (onEntry, onExit and transition actions) are meant to be fire-and-forget side-effects that should not have any effect on the machine's state (except for raise() and send() actions). There was a similar question here about that: https://github.com/davidkpiano/xstate/issues/243#issuecomment-439995493
Since you want to group behavior, it might make sense for one, two, and three to be nested in a parent state, and have invoke be on that state:
const machine = Machine({
id: 'machine',
initial: 'default',
states: {
default: {
on: {
ONE: 'other.one',
TWO: 'other.two',
THREE: 'other.three',
}
},
other: {
invoke: {
// `saveToDb(...)` returns a Promise
src: (ctx, event) => saveToDb({ name: event.name }),
onDone: {
// here, the payload from the promise is in `doneEvent.data`
actions: assign({ newId: (ctx, doneEvent) => doneEvent.data })
}
},
initial: 'one',
states: {
one: {},
two: {},
three: {}
}
}
}
});
Above, the saveToDb service will only be invoked when other is entered (as it lasts for the duration of the state, though it may finish sooner).
To me, this makes more sense, since (if you were to visualize this) it's clear that this shows that "when the states in 'other' (other.one other.two or other.three) are entered, save to the database", which is the logic you want to represent.
Thanks so much for the quick response, @davidkpiano ! I'll try a different design for my state machines and see how that feels.
After experimenting some more, now I have another question/suggestion, related but different from my previous example -- seems very much related to #243 but I didn't totally understand that thread and didn't find the Flickr Gallery example code in the docs (?). So my related question is:
➡️ How does xstate handle context updates that result from async code, which are not associated with a state change?
I know that xstate allows me to:
Example:
I'm making a time tracking app, and there are some obvious state changes like "timing" --> "paused" (which can invoke saving to the db). But if the user switches from one task to another without resetting the timer, this also needs to save a new entry to the db -- however, in this case the state ("timing") doesn't change. Only one piece of the context changes: the unique ID for the current task.
To me, it seems most intuitive to categorize this context update as a side effect, not a state change:
If I can use an action to update the context synchronously without requiring a state change, I'd expect to also be able to update the context asynchronously without a state change... I haven't figured out how to do this from reading the docs and example code yet though.
I realize that I could design this app with additional states and make it work, but it feels unintuitive to me... So I'm just curious to bring this up for discussion and learn more about the trade-offs and design philosophy here. :) Thanks!
How does xstate handle context updates that result from async code, which are not associated with a state change?
In state machines/Actor model/etc, all state changes (extended or finite) are caused by an event (which sorta models real life - things don't just change magically for no reason, there's always _something_ that causes a change in state).
I'd expect to also be able to update the context asynchronously without a state change
This makes sense, and you can model your state machine this way, but there is a reason (like stated above) that you can't just arbitrarily change extended state at any time: it makes the machine less deterministic, and is also the source of many, many bugs in everyday programming.
For example, suppose you want to fire off some sort of action that updates the user's name, and pretend you can do something like this:
// disclaimer: pseudocode
onEntry: (ctx, e) => {
someService.updateUserName(e.id, e.name)
.then(data => assign({ user: (ctx) => ({...ctx.user, name: data.name }) }));
}
But then, what happens if the user is _deleted_ before this promise resolves? Then this becomes ⚠️ _undesirable behavior_ because we don't want to assign a name to an undefined user.
In the Actor model:
...actors are completely isolated from each other and they will never share memory. It’s also worth noting that an actor can maintain a private state that can never be changed directly by another actor.
With state machines, we can consider each "machine" an "actor" that has its own internal state (context), and communicates with other "actors" (like other machines, Promises, Observables, etc.) via message passing.
Using invoke starts a sort of "communication stream" between two actors (the machine and the invoked service) so that the service might be able to send it messages (as events), and the machine can handle those events however it wants to.
To make another analogy, if you're an Actor (we're all state machines 😛) and the bank is another Actor, we can't directly add/remove money from the bank (that's illegal). Instead, we _communicate_ with the bank (via tellers, ATMs, etc.) letting it know what we want to do, and the bank determines what happens next (that is, it keeps its own internal state and changes it itself, based on its own logic).
Here is a diagram explaining the three different types of "communication/side-effects" that can happen in a machine:
onEntry, onExit, actions) are "fire-and-forget" and do not affect the internal machine statesrc: (ctx, e) => Promise will communicate back to the parent machine _once_ (when it is resolved/rejected)src: (ctx, e) => (cb) => ... will communicate back to the parent machine every time cb(event) is called.
That diagram / quick explainer is beautiful :) Will be great material for future updates to the docs!
I guess the root of my confusion might just be the difference between side effects vs state updates, since the examples for assign() in the docs show making context updates via actions, as in:
actions: {
increment: assign({ count: ctx => ctx.count + 1 }),
decrement: assign({ count: ctx => ctx.count - 1 })
}
But to me, your comments in this thread seem to conflict with the docs:
context as being part of a machine's internal state...context (as shown in the docs), isn't it then updating the machine's state? And wouldn't that make the context-updating action very much not just a side effect?So is the code snippet above an example of a side effect, or an example of changing the internal state?
My attempt at clarifying/rewording the definitions here in a way that "clicks" better for me:
All updates to context/internal state must be triggered directly by an event, in one of 3 ways:
onDone event of an invoked service (eg, the result of a single async action),As for side effects: The way I understand it, there are no limitations on how side effects can be triggered. For example, this code should work (I think???):
onEntry: (ctx, e) => {
someService.updateUserName(e.id, e.name).then(data => {
document.body.textContent = data; // <- just a side effect, so this code will run
});
}
Further clarification:
A synchronous context/state update works just fine because it's directly triggered by an event. But an async context/state update won't work if it's one step removed from the triggering event:
Direct, synchronous (this works):
event COUNTUP -> action: assign(newContext)
Indirect, asynchronous (this won't work):
event UPDATEUSER -> action: "dbUpdateUser"
-> delay, Promise resolves -> action: assign(newContext)
Via service, async but each action is directly after an event (this works):
event UPDATEUSER -> invoke: "dbUpdateUser" -> delay, Promise resolves ->
event: onDone -> action: assign(newContext)
❓ Do you think that's a good way to clarify the distinction?
Sorry to be such a stickler here! :sweat_smile: I'm trying to wrap my head around this and tease out some details that could be good targets for updates to the docs (which I'm happy to help with).
Thank you for the addition prompt, thoughtful, and detailed replies @davidkpiano!
...an async context/state update won't work if it's one step removed from the triggering event.
Yep, exactly this! That's a great way to put it, thanks.
I'm going to add more sections in the Recipes section of the docs with a lot of your suggested descriptions in there, because I do agree that it needs clarification. It strictly follows the Actor model, where the only thing that can ever update an Actor's internal state is the actor itself, and it can update its state at any time. That's why onEntry: assign(...) just works, because it's something that the state machine (Actor) is doing _to itself_.
@davidkpiano Thank you for the detailed response above. Did you every get a chance to transition this over to the documentation? I don't remember seeing it, but I think this would be very helpful. I too am having trouble wrapping my head around how xstate works with async code and this is maybe the best description I have seen so far.
The only further question I would ask above is how to handle the case where while the async invoke on the service is in process, another event comes in that would change the state of the machine. (ie. what happens to the pending async action and what is best practice here). This would definitely be a useful recipe to document as having the state change while async work is occurring seems to me to be one of the largest sources of bugs in application software.
how to handle the case where while the async invoke on the service is in process, another event comes in that would change the state of the machine.
If it's an invoked promise and the state changes (to a state where the promise is canceled), the result of that promise is discarded.
If it's an invoked callback, the "cleanup" function is called.
If it's an observable, it is unsubscribed.
If it's a machine, it is stopped.
@davidkpiano Thank you so much for the concise and clear answer. It makes sense why it would do this in all cases. I created a PR to potentially add this to the documentation. see: https://github.com/davidkpiano/xstate/pull/616
Feel free to ignore or clarify if you think it is useful.
Most helpful comment
Here is a diagram explaining the three different types of "communication/side-effects" that can happen in a machine:
onEntry,onExit,actions) are "fire-and-forget" and do not affect the internal machine statesrc: (ctx, e) => Promisewill communicate back to the parent machine _once_ (when it is resolved/rejected)src: (ctx, e) => (cb) => ...will communicate back to the parent machine every timecb(event)is called.