Handling several parallel async operations with XState

12 oct 2022

Table of contents

TL;DR

In this article we see how to handle several async operations, here HTTP requests, from a client using XState Promises invocations and Parallel states.

Thanks to both we’re able to design a machine that will invoke at the same time several promises. On those promises resolution or rejection each parallel states can transition or not to its final state. Depending on every parallel children current state we can determine if every HTTP request has been loaded successfully.

See the machine configuration →

See the machine component integration →

Introduction

Our need is to await for two async sources to finish before letting the user going further. Lets say for the example, waiting for the response of 2 HTTP requests before allowing the user to interact with the view. In our example we’ll take both the UserInformation and UserCart requests.

In the SCXML specification, the very first elements that defines a State Machine are States listening to Events allowing or not Transitions to other States. It exists several layers where we can run specific Actions, Services and Conditions, as for example when entering/leaving a State or receiving a specific event and more. A State machine carries a Context that can be updated on Actions as for example storing new fetched information or whatever.

XState is a library that allows us to write state machines in JavaScript. It comes with a lot a framework related packages, such as React Vue Angular and more, to be able to interact and interpret the logic a machine can be carrying from any view.

Coming back to our need, the goal of our machine is to load, lets say all a user’s data, before achieving to its final state. To do so we’ll be using the promises invocation and parallel states. Before achieving to our final machine answering to our need, we’re going to see both of above points one by one.

Invoking promises with XState

XState thanks to its Invoke API, allows us to invoke Callbacks/Machines/Observables/Promises. In our case we will be invoking promises, and more precisely HTTP services.

Lets think about a machine that when interpreted instantly sends an HTTP request to a server in order to retrieve a user instance. That machine needs to have an initial state that invoke our fetching promise, lets call that state Loading user.

In that state thanks to invoke we run the user fetch, then within the same state we listen to both failing and success use cases using the onDone and onError properties, that respectively handle the promise resolution and promise rejection.

In a success case we transition to a Loaded user successfully state and assign to the machine’s context the fetched user using an action Assign fetched user to context, otherwise we transition to Loading user failed state. Lets be fancy ! Inside the Loading user failed state we listen to a RETRY event, only available when the machine is in this state ! On its reception we transition to the Loading user state.

In this example I use the XState typegen to be able to strongly type any of my machine properties.

Typed fetch user example:

import { assign, createMachine } from "xstate";

interface User {
  userId: number;
  userEmail: string;
}

const fetchUser = (userId: number): Promise<User> =>
  fetch(`url/to/user/${userId}`).then((response) => response.json());

interface FetchUserMachineContext {
  userId: number;
  userEmail?: string;
}

// Machine definition
export const userMachine = createMachine(
  {
    id: "userMachine",
    // The default state the machine joins
    initial: "Loading user",
    schema: {
      context: {} as FetchUserMachineContext,
      services: {} as {
        "Fetch User": {
          data: User;
        };
      },
    },
    // This is for typing purpose for more information have a look to
    // https://xstate.js.org/docs/guides/typescript.html#typegen
    tsTypes: {} as import("./FetchUserMachine.typegen").Typegen0,
    context: {
      userId: 42,
      userEmail: undefined,
    },
    states: {
      "Loading user": {
        // From the moment the machine is interpreted it triggers the following invoke
        // That leads to perform an HTTP request
        invoke: {
          id: "getUser",
          src: "Fetch User",

          // On promise resolution
          onDone: {
            target: "Loaded user successfully",
            // If the response is ok then we update the machine context from the retrieved data using an action
            actions: "Assign fetched user to context",
          },

          // On promise Rejection
          onError: {
            target: "Loading user failed",
          },
        },
      },

      "Loaded user successfully": {
        type: "final",
      },

      "Loading user failed": {
        // In this state we're listening to a specific event RETRY
        // Note that if the machine intercepts a RETRY event while being in loading state
        // It won't be interpreted at all
        on: {
          RETRY: { target: "Loading user" },
        },
      },
    },
  },
  {
    actions: {
      // Note thanks to the typegen we don't even have to type check the received event below !
      "Assign fetched user to context": assign({
        userEmail: (_context, event) => event.data.userEmail,
      }),
    },
    services: {
      "Fetch User": async (context) => await fetchUser(context.userId),
    },
  }
);

Note that just like that, when the machine is not interpreted ! It’s just an object. We will see later a way to interpret an equivalent machine later.

Parallel states nodes

We’ve seen how to handle one async operation within a state machine. But our goal is to handle at least two of them at the same time. In this way we will need our machine to be able to “be” in several states at once.

This is when the parallel states nodes make an entrance. It exists several machine states node types:

  • An atomic state node has no child states. (I.e., it is a leaf node.)
  • A compound state node contains one or more child states, and has an initial state, which is the key of one of those child states.
  • A parallel state node contains two or more child states, and has no initial state, since it represents being in all of its child states at the same time.
  • A final state node is a leaf node that represents an abstract “terminal” state.
  • A history state node is an abstract node that represents resolving to its parent node’s most recent shallow or deep history state.

From there we can imagine a root parent state that has two states child, one being the parallel states handler and the other being the machine final state nor further logic. Inside the parallel states would be found two parallel states waiting for their specific resources to be fulfilled by the user before reaching their final states.

A parallel parent state can listen its parallel children final state transitions through the onDone property.

import { createMachine } from "xstate";

type ParallelMachineExampleEvents =
  | {
      type: "User fulfilled resource 1";
    }
  | {
      type: "User fulfilled resource 2";
    };

export const machine = createMachine({
  id: "parallel machine example",
  schema: {
    events: {} as ParallelMachineExampleEvents,
  },
  // This is for typing purpose for more information have a look to
  // https://xstate.js.org/docs/guides/typescript.html#typegen
  tsTypes: {} as import("./myTestMachine.typegen").Typegen0,
  initial: "Pending until user fulfilled all required resources",
  states: {
    "Pending until user fulfilled all required resources": {
      type: "parallel",
      // The onDone property we'll be triggered when every children states
      // reach their final state
      onDone: "All resources has been fulfilled by the user",
      // We do not declare any initial state there as the machine
      // is in every child states at once

      states: {
        "Resource 1 handler": {
          type: "compound",
          initial: "Waiting for user input",

          states: {
            "Waiting for user input": {
              on: {
                "User fulfilled resource 1": {
                  target: "Success",
                },
              },
            },

            // Resource 1 handler final state is Success
            Success: {
              type: "final",
            },
          },
        },

        "Resource 2 handler": {
          type: "compound",
          initial: "Waiting for user input",

          states: {
            "Waiting for user input": {
              on: {
                "User fulfilled resource 2": {
                  target: "Success",
                },
              },
            },

            // Resource 2 handler final state is also Success
            Success: {
              type: "final",
            },
          },
        },
      },
    },

    "All resources has been fulfilled by the user": {
      // The machine reaches its global final state
      type: "final",
    },
  },
});

Note that again, we’ve haven’t seen how to interact with the machine from the outside, such as sending it events. We’re going to see this in the section just below ! where we use both XState parallel states and Promises invocation.

Combining both

Just as a reminder in the introduction section we’ve listed that our need was to await for two async source to finish before going further, taking as an example a UserCart and UserInformation loading from server endpoints.

Our machine will then need to:

In it’s initial first state Idle, listen for an event to transition to a Load user data parallel state. In this Load user data parallel state will be defined two parallel states that will both invoke a specific service, both being UserCart or UserInformation fetching functions. The Load user data state on its onDone property, when all its children are in final states, will transition to a Loaded user data state.

As we can see our machine states are quite long sentences. This is not mandatory but I really like this ability to give meaning to the code by it’s definition. Whatever is your business logic or your need, you can totally describe what state is expecting and used for. Also note that thanks to the typegen the autocompletion is even more allowing it.

In the following example nothing new ! The idea is the same as the other above example codes. We put in parallel states that invoke promises. On every promises validation we transition thanks to the parent parallel state node onDone property to a further state.

Note that the "Fetch user cart" and "Fetch user information" services are not defined within the machine configuration, when interpreting the machine we will have to provide them. Their definition doesn’t relate to the machine itself, it’s recommended to think about a machine as a pure entity without specific framework or business dependencies. In this way we could integrate this machine anywhere the need is.

import type { UserCart, UserInformation } from "@/type";
import { assign, createMachine } from "xstate";

type LoadUserDataMachineEvents =
  | {
      type: "User pressed load user data button";
    }
  | {
      type: "User pressed reset machine button";
    };

type LoadUserDataMachineContext = {
  userInformation?: UserInformation;
  userCart?: UserCart;
};

export const createLoadUserDataMachine = () => {
  return createMachine(
    {
      id: "loadUserDataMachine",
      tsTypes: {} as import("./LoadUserDataMachine.typegen").Typegen0,
      schema: {
        services: {} as {
          "Fetch user information": {
            // The data that gets returned from the service
            data: UserInformation;
          };
          "Fetch user cart": {
            data: UserCart;
          };
        },
        events: {} as LoadUserDataMachineEvents,
        context: {} as LoadUserDataMachineContext,
      },
      context: {
        userInformation: undefined,
        userCart: undefined,
      },
      initial: "Idle",
      states: {
        Idle: {
          on: {
            "User pressed load user data button": {
              target: "Load user data",
            },
          },
        },

        "Load user data": {
          type: "parallel",
          onDone: {
            target: "Loaded user data",
          },

          states: {
            "Loading user information": {
              initial: "Fetching user information from server",

              states: {
                "Fetching user information from server": {
                  tags: "Loading user information",

                  invoke: {
                    src: "Fetch user information",

                    onDone: {
                      target: "Loaded user information",
                      actions: "Assign loaded user information to context",
                    },

                    onError: {
                      target: "Loading user information failed",
                    },
                  },
                },

                "Loading user information failed": {
                  tags: "Loading user information failed",
                  on: {
                    "User pressed load user data button": {
                      target: "Fetching user information from server",
                    },
                  },
                },

                "Loaded user information": {
                  type: "final",
                },
              },
            },

            "Load user cart": {
              initial: "Fetching user cart from server",
              states: {
                "Fetching user cart from server": {
                  tags: "Loading user cart",

                  invoke: {
                    src: "Fetch user cart",

                    onDone: {
                      target: "Loaded user cart",
                      actions: "Assign loaded user cart to context",
                    },

                    onError: {
                      target: "Loading user cart failed",
                    },
                  },
                },

                "Loading user cart failed": {
                  tags: "Loading user cart failed",
                  on: {
                    "User pressed load user data button": {
                      target: "Fetching user cart from server",
                    },
                  },
                },

                "Loaded user cart": {
                  type: "final",
                },
              },
            },
          },
        },

        "Loaded user data": {
          on: {
            "User pressed reset machine button": {
              target: "#loadUserDataMachine.Idle",
              actions: "Reset machine context",
            },
          },
        },
      },
    },
    {
      actions: {
        "Assign loaded user information to context": assign({
          userInformation: (_context, event) => {
            return event.data;
          },
        }),
        "Assign loaded user cart to context": assign({
          userCart: (_context, event) => {
            return event.data;
          },
        }),

        "Reset machine context": assign({
          userCart: (_context, _event) => undefined,
          userInformation: (_context, _event) => undefined,
        }),
      },
    }
  );
};

Machine integration within a Vue component

Until now, we’ve been thinking about the core logic, our machine definition. Now we want to be able to communicate with it within a component, to do so we will be using Vue3 with its composition API. Note that XState provides packages for:

The idea is not to cover the whole xstate/vue package but to check what we have to user to be able to communicate with the machine from a Vue component. The integration on an react app would look very similar.

Creating and interpreting the machine

First we need to instantiate our machine using the previously created createLoadUserDataMachine function, that itself uses the createMachine XState function. From there we will pass our state machine to the useMachine hook, note that we also pass the machine configuration required services.

<script setup lang="ts">
import { useMachine } from "@xstate/vue";
import { fetchUserCart, fetchUserInformation } from "@/services/UserService";
import { createLoadUserDataMachine } from "@/machines/LoadUserDataMachine";

// Creating the machine
const loadUserInformationMachine = createLoadUserDataMachine();

// Interpreting the machine
const { send: sendToLoadUserDataMachine, state: loadUserDataMachineState } =
  useMachine(loadUserInformationMachine, {
    services: {
      "Fetch user information": async () => await fetchUserInformation(),
      "Fetch user cart": async () => await fetchUserCart(),
    },
  });

// ...
</script>

From there we have access to a running instance of our machine. The send method allows us to send events to the machine and the state being a Vue3 shallowRef of the machine state. Within a machine state we can find, the machine current state value, the machine context and more ! see documentation.

Communicating with the machine

In our machine configuration we’ve described only two events and . User pressed load user data button for starting loading everything and User pressed load user data button for resetting the machine context and state. Coming back to our previous Vue component code we can add two button onClick handlers using the renamed machine send method.

<script setup lang="ts">
// ...

function sendUserPressedLoadUserDataToMachine() {
  sendToLoadUserDataMachine({
    type: "User pressed load user data button",
  });
}

function sendResetContextToMachine() {
  sendToLoadUserDataMachine({
    type: "User pressed reset machine button",
  });
}

// ...
</script>

Also we will need to update our view depending on the machine current state. To avoid wobbly assertions on parallel states current values we will be using XState tags ! Lets say that using state.hasTags("my-tag") will return true if the current machine state or its parent has my-tag. Then lets define two computed getting both async operations status depending on hasTag returned value.

<script setup lang="ts">
// ...

const showLoadUserDataButton = computed(
  // The first .value is for accessing the ShallowRef current value
  // The second is the machine current state
  () => loadUserDataMachineState.value.value === "Idle"
);

function getUserInformationStatus(): StatusLabel {
  if (
    loadUserDataMachineState.value.hasTag("Loading user information failed")
  ) {
    return "failed";
  }

  if (loadUserDataMachineState.value.hasTag("Loading user information")) {
    return "loading";
  }

  return "success";
}
const userInformationStatus = computed(() => getUserInformationStatus());

function getUserCartStatus(): StatusLabel {
  if (loadUserDataMachineState.value.hasTag("Loading user cart failed")) {
    return "failed";
  }

  if (loadUserDataMachineState.value.hasTag("Loading user cart")) {
    return "loading";
  }

  return "success";
}
const userCartStatus = computed(() => getUserCartStatus());

// ...
</script>

The final component using every of the three above points !

<script setup lang="ts">
import { computed } from "vue";
import { useMachine } from "@xstate/vue";
import BaseButton from "./kit/BaseButton.vue";
import { fetchUserCart, fetchUserInformation } from "@/services/UserService";
import type { StatusLabel } from "@/type";
import StatusSection from "./kit/StatusSection.vue";
import { createLoadUserDataMachine } from "@/machines/LoadUserDataMachine";

const loadUserInformationMachine = createLoadUserDataMachine();

const { send: sendToLoadUserDataMachine, state: loadUserDataMachineState } =
  useMachine(loadUserInformationMachine, {
    services: {
      "Fetch user information": async () => await fetchUserInformation(),
      "Fetch user cart": async () => await fetchUserCart(),
    },
  });

function sendUserPressedLoadUserDataToMachine() {
  sendToLoadUserDataMachine({
    type: "User pressed load user data button",
  });
}

function sendResetContextToMachine() {
  sendToLoadUserDataMachine({
    type: "User pressed reset machine button",
  });
}

const showLoadUserDataButton = computed(
  () => loadUserDataMachineState.value.value === "Idle"
);

function getUserInformationStatus(): StatusLabel {
  if (
    loadUserDataMachineState.value.hasTag("Loading user information failed")
  ) {
    return "failed";
  }

  if (loadUserDataMachineState.value.hasTag("Loading user information")) {
    return "loading";
  }

  return "success";
}
const userInformationStatus = computed(() => getUserInformationStatus());

function getUserCartStatus(): StatusLabel {
  if (loadUserDataMachineState.value.hasTag("Loading user cart failed")) {
    return "failed";
  }

  if (loadUserDataMachineState.value.hasTag("Loading user cart")) {
    return "loading";
  }

  return "success";
}
const userCartStatus = computed(() => getUserCartStatus());
</script>

<template>
  <main class="flex flex-col justify-start items-center mt-6">
    <div data-cy="machine-current-value">
      {{ loadUserDataMachineState.value }}
    </div>
    <div class="flex flex-col">
      <template v-if="showLoadUserDataButton">
        <h4>Will be downloaded:</h4>
        <ul class="list-disc">
          <li>User Information (id, name, email, etc.)</li>
          <li>User Cart (items, credit, etc.)</li>
        </ul>
        <BaseButton
          data-cy="load-user-data-button"
          @click="sendUserPressedLoadUserDataToMachine"
        >
          Load user Data
        </BaseButton>
      </template>

      <template v-else>
        <!-- Loading -->
        <div class="flex flex-col justify-center items-start m-auto">
          <StatusSection
            v-bind:status="userInformationStatus"
            label="User Information"
            test-id="user-information"
          />

          <StatusSection
            v-bind:status="userCartStatus"
            label="User Cart"
            test-id="user-cart"
          />

          <template
            v-if="
              userInformationStatus === 'failed' || userCartStatus === 'failed'
            "
          >
            <BaseButton
              data-cy="retry-button"
              @click="sendUserPressedLoadUserDataToMachine"
            >
              Retry
            </BaseButton>
          </template>
        </div>

        <!-- Loaded -->
        <template
          v-if="
            userInformationStatus === 'success' && userCartStatus === 'success'
          "
        >
          <span class="mb-2">Reached final state</span>
          <span class="mb-2">{{
            loadUserDataMachineState.context.userInformation
          }}</span>
          <span class="mb-2">{{
            loadUserDataMachineState.context.userCart
          }}</span>
          <BaseButton @click="sendResetContextToMachine">
            Reset the machine
          </BaseButton>
        </template>
      </template>
    </div>
  </main>
</template>

Check below link to see the final result check the production example using msw.

Final result production example →

Ending

We’ve seen how to combine both XState promises invocations and parallel states. Thanks to XState we’re able to build human readable configuration but also an exportable isolated logic outside the view. It only determines what to display to the user depending on the exposed machine state. Having the logic centralized inside machines allows anyone that knows XState to understand and iterate over the machine abstracting the overall used framework.

Working Example

You can find a tested working reproduction of the UserCart and UserInformation using Vue3 and Cypress e2e testing & @xstate/test:

XState handling async operations repository →

XState handling async operations production →