DEV Community

Mikey Stengel
Mikey Stengel

Posted on • Edited on

State machine advent: Asynchronous code in XState (19/24)

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 node fetching.
  • One state node should represent the fulfilled state of the promise. We'll call this one success.
  • (Optionally) One state node that represents the rejected state of the promise. We'll call it failure.
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[];
}
Enter fullscreen mode Exit fullscreen mode

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: {},
  }
})
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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'
      },
    },
  },
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
karfau profile image
Christian Bewernitz

Did I understand correctly that onDone and onError 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?

Collapse
 
codingdive profile image
Mikey Stengel • Edited

Yes, onDone and onError 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.

'FETCH_CATS': {
  target: 'fetching',
  // optionally actions: []
},

The event handlers of the service use the exact same syntax to specify the next state and actions to take.

What happens if they are not provided?

onError and onDone 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 :)

Collapse
 
isaacsouza17 profile image
isaacsouza17

Is there a git repo with all the code?