Most applications are filled with asynchronous code. It's a shame when state management libraries do not support to write such code out of the box. Luckily, in XState multiple ways to handle asynchronicity exist. Today, we want to explore how to deal with promises and their superset - async functions.
Since most things in XState are modeled using actions and state transitions, let's take a look at how those two concepts translate to the invocation of a promise.
A promise is a state machine that at any point in time is either pending | fulfilled | rejected
. When we want to call a promise within a state machine, the first thing we want to do is to represent the three possible states as state nodes inside the machine.
Let's say we want to create a state machine that fetches cat images from an external API.
- One state node should represent the
pending
state of the promise. This is where we want to call the promise to fetch cat images. The promise will be invoked every time we enter the state node. Let's call this state nodefetching
. - One state node should represent the
fulfilled
state of the promise. We'll call this onesuccess
. - (Optionally) One state node that represents the
rejected
state of the promise. We'll call itfailure
.
interface CatFetchStateSchema {
idle: {};
fetching: {};
success: {};
failure: {};
}
type CatFetchEvent = { type: 'FETCH_CATS'};
interface CatFetchContext {
/**
* We also want to track error messages. After all, should the promise be rejected, the least we can do is to let the user know why they can't look at cat pictures 😿 (Did you know that a crying cat emoji exists? lol)
*/
errorMessage?: string;
cats: any[];
}
We can then implement our state machine.
import { Machine, assign } from 'xstate';
const catFetchMachine = Machine<CatFetchContext, CatFetchStateSchema, CatFetchEvent>({
id: 'catFetch',
initial: 'idle',
context: {
errorMessage: undefined,
cats: [],
},
states: {
idle: {
on: {
'FETCH_CATS': {
target: 'fetching',
},
},
},
fetching: {
invoke: {
id: 'retrieveCats',
src: (context, event) => fetchCats(),
onDone: {
target: 'success',
actions: assign({ cats: (context, event) => event.data })
},
onError: {
target: 'failure',
actions: assign({ errorMessage: (context, event) => event.data })
}
}
},
success: {},
failure: {},
}
})
The invoke
property indicates that we are invoking something that does not return a response immediately. Since the response occurs at some point in the future, we define an error and success handler. They will be called when the promise is rejected or fulfilled respectively. In the onError
and onDone
event handlers, we can define the next state (value of target
) and actions. Actions are used to perform side effects such as assigning a new value to the context.
Since we typically express state changes with state transitions and actions anyway, dealing with asynchronous code in XState is a breeze!
Another thing that makes me happy when dealing with async code in XState is exception management. Normally our fetchCats
code would look something like this:
const fetchCats = async () => {
try {
const catResponse = await fetch('some-cat-picture-api');
const cats = await catResponse.json().data;
return cats;
} catch (error){
console.error("Something went wrong when fetching cats 😿", error);
// handle error
}
}
Because of the onError
handler, we have moved the exception management into our state machine. As a result, we need to ensure the promise can be rejected and can happily remove the try-catch block from the async function:
const fetchCats = async () => {
const catResponse = await fetch('some-cat-picture-api');
const cats = await catResponse.json().data;
return cats;
}
Granted, with the machine implementation from above, cats will only be fetched once. We can fix this by adding some state transitions back to the fetching
state.
success: {
on: {
'MORE_CATS': {
target: 'fetching'
},
},
},
failure: {
on: {
'RETRY': {
target: 'fetching'
},
},
},
Now the user can recover our machine from a failure
state and also fetch more/different cats.
In summary, to perform asynchronous code in XState:
- translate the three promise states into state nodes (
pending = fetching
,fulfilled = success
,rejected = failure
) - define state transitions and actions in the error or success event handlers
- give the object that invokes the promise (technically called a service) a unique id
- ensure that promises can be rejected by removing the try-catch blocks from asynchronous functions
Very excited to have finally introduced the invoke
property as we'll come back to it in the next couple of days when exploring some of the other things that can be invoked in XState.
About this series
Throughout the first 24 days of December, I'll publish a small blog post each day teaching you about the ins and outs of state machines and statecharts.
The first couple of days will be spent on the fundamentals before we'll progress to more advanced concepts.
Top comments (3)
Did I understand correctly that
onDone
andonError
are the keys that have to be used for those actions, as in the keys are the convention XState relies on?And what happens if they are not provided?
Yes,
onDone
andonError
are properties of XState. They are handling the events of whatever you are invoking. When invoking a promise, the only two events you can have are fulfilled or rejected.With a normal event, you also specify a target and actions.
The event handlers of the service use the exact same syntax to specify the next state and actions to take.
onError
andonDone
are both optional so you can totally omit them for "fire and forget" services.You can read more about the invoke property here which also explains some properties I haven't covered :)
Is there a git repo with all the code?