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
)