XState offers several ways of orchestrating side effects. Since it's a statechart tool, with significantly more power than a reducer, side effects are treated as a first-class concept.
'Side effects' as a concept spring from the idea of 'pure' functions. I.e. a function is at its best when it takes an input and returns an output. Pure functions don't tend to involve:
- Waiting a set amount of time
- Making an API call to an external service
- Logging things to an external service
So we can think of all of the above as 'side effects' of our programme running. The name gives them a negative, medical, connotation - but really, they're the meat of your app. Apps that don't have side effects don't talk to anything external, don't worry about time, and don't react to unexpected errors.
Actions
"Thank u, next." - Ariana Grande
Side effects can be expressed in two ways in XState. First, as a fire-and-forget action
. Let's imagine that when the user opens this modal, we want to report to a logging service that this happened.
const modalMachine = createMachine(
{
initial: 'closed',
states: {
closed: {
on: {
OPEN: 'open',
},
},
open: {
entry: ['reportThatUserOpenedTheModal'],
on: {
CLOSE: 'closed',
},
},
},
},
{
actions: {
// Note that I'm declaring the action name above, but implementing
// it here. This keeps the logic and implementation separate,
// which I like
reportThatUserOpenedTheModal: async () => {
await fetch('/external-service/user-opened-modal');
},
},
},
);
Actions are fire-and-forget, which means you can fire them off without worrying about consequences. If the action I declared above errors, my state machine won't react - it's already forgotten about it.
Actions represent a single point in time, like a dot on a graph. This means that you can place them very flexibly. Above, I've placed them on the entry
attribute of the state, meaning that action will be fired when we enter the state. I could also place them on the transition:
const modalMachine = createMachine(
{
initial: 'closed',
states: {
closed: {
on: {
OPEN: {
actions: 'reportThatUserOpenedTheModal',
target: 'open',
},
},
},
open: {
on: {
CLOSE: 'closed',
},
},
},
},
{
actions: {
reportThatUserOpenedTheModal: async () => {
// implementation
},
},
},
);
This means that whenever OPEN
is called from the closed
state, that action will be fired. In this modal, we could also fire the action whenever the user leaves the closed
state, since we know they'll be going to the open
state.
const modalMachine = createMachine(
{
initial: 'closed',
states: {
closed: {
on: {
OPEN: 'open',
},
exit: ['reportThatUserOpenedTheModal'],
},
open: {
on: {
CLOSE: 'closed',
},
},
},
},
{
actions: {
reportThatUserOpenedTheModal: async () => {
// implementation
},
},
},
);
Actions are flexible precisely because we don't care about their outcome. We can hang them on the hooks our state machine gives us: transitions between states, exiting states and entering states.
Services
"And I plan to be forgotten when I'm gone..." - The Tallest Man on Earth
But actions have a specific limitation - they are designed to be forgotten. Let's imagine that you wanted to track whether the analytics call you made was successful, and only open the modal if it was. We'll add an opening
state which handles that check.
const modalMachine = createMachine({
initial: 'closed',
states: {
closed: {
on: {
OPEN: 'opening',
},
},
opening: {
entry: [
async () => {
await fetch('/external-service/user-opened-modal');
// OK, this was successful - how do I get to the open state?!
},
],
},
open: {
on: {
CLOSE: 'closed',
},
},
},
});
This isn't possible in an action. We can't fire back an event to the machine, because it's already forgotten about us.
When you care about the result of an action, put it in a service. This is how this would be expressed as a service:
const modalMachine = createMachine({
initial: 'closed',
states: {
closed: {
on: {
OPEN: 'opening',
},
},
opening: {
invoke: {
// This uses the invoked callback syntax - my favourite
// syntax for expressing services
src: () => async (send) => {
await fetch('/external-service/user-opened-modal');
send('OPENED_SUCCESSFULLY');
},
onError: {
target: 'closed',
},
},
on: {
OPENED_SUCCESSFULLY: {
target: 'open',
},
},
},
open: {
on: {
CLOSE: 'closed',
},
},
},
});
If actions are a dot on the graph, services represent a line - a continuous process which takes some amount of time. If the service errors, it'll trigger an event called error.platform.serviceName
, which you can listen for with the onError
attribute, as above.
Crucially, they can also send events back to the machine, using the send
function above. Notice that we're both sending back the OPENED_SUCCESSFULLY
event and listening to it in the on: {}
attribute of the opening
state.
Services are less flexible than actions, because they demand more from you. You can't hang them on every hook your machine offers. They must be contained within one state, and they're cancelled when you leave that state. (Note: they can also be at the root of the machine definition, meaning they run for the lifetime of the machine.)
Guidelines
Actions are the 'thank u, next' of the XState world. They represent points in time. Use them for fire-and-forget actions. Actions are great for:
console.log
- Showing ephemeral error or success messages (toasts)
- Navigating between pages
- Firing off events to external services or parents of your machine
Services are like a 'phase' your machine goes through. They represent a length of time. Use them for processes where you care about the outcome, or you want the process to run for a long time. Services are great for:
- API calls
- Event listeners (
window.addEventListener
)
Top comments (1)
Hi Matt, thanks for the post. Just wondering about the reason behind using send('OPENED_SUCCESSFULLY') instead of using onDone. Does it have any advantages?