XState: onError and explicit states

Since in XState states are explicit by default, we don't actually need to change much.

This is our original implementation, notice how the states ("Editing", "Loading", "Complete") are already encoded in the machine.

const machine = setup({
  types: {
    context: {} as Context,
    events: {} as Event,
  },
  actors: { submitActor },
}).createMachine({
  context: initialContext,
  initial: "Editing",
  states: {
    Editing: {
      on: {
        "update-username": {
          actions: assign(({ event }) => ({
            username: event.username,
          })),
        },
        submit: { target: "Loading" },
      },
    },
    Loading: {
      invoke: {
        src: "submitActor",
        input: ({ event, context }) => {
          assertEvent(event, "submit");
          return { event: event.event, context };
        },
        onDone: { target: "Complete" },
      },
    },
    Complete: {},
  },
});

We therefore just need a new "Error" state:

const machine = setup({
  types: {
    context: {} as Context,
    events: {} as Event,
  },
  actors: { submitActor },
}).createMachine({
  context: initialContext,
  initial: "Editing",
  states: {
    Editing: {
      on: {
        "update-username": {
          actions: assign(({ event }) => ({
            username: event.username,
          })),
        },
        submit: { target: "Loading" },
      },
    },
    Loading: {
      invoke: {
        src: "submitActor",
        input: ({ event, context }) => {
          assertEvent(event, "submit");
          return { event: event.event, context };
        },
        onDone: { target: "Complete" },
      },
    },
    Error: {},
    Complete: {},
  },
});

When an invoked actor fails, invoke will trigger onError and allow us to transition to the "Error" state:

Whereas onDone is executed when the Promise resolves successfully, onError is executed when the Promise rejects.

const machine = setup({
  types: {
    context: {} as Context,
    events: {} as Event,
  },
  actors: { submitActor },
}).createMachine({
  context: initialContext,
  initial: "Editing",
  states: {
    Editing: {
      on: {
        "update-username": {
          actions: assign(({ event }) => ({
            username: event.username,
          })),
        },
        submit: { target: "Loading" },
      },
    },
    Loading: {
      invoke: {
        src: "submitActor",
        input: ({ event, context }) => {
          assertEvent(event, "submit");
          return { event: event.event, context };
        },
        onError: { target: "Error" },
        onDone: { target: "Complete" },
      },
    },
    Error: {},
    Complete: {},
  },
});

Inside "Error" we capture the same events as "Editing" (update-username and submit):

const machine = setup({
  types: {
    context: {} as Context,
    events: {} as Event,
  },
  actors: { submitActor },
}).createMachine({
  context: initialContext,
  initial: "Editing",
  states: {
    Editing: {
      on: {
        "update-username": {
          actions: assign(({ event }) => ({
            username: event.username,
          })),
        },
        submit: { target: "Loading" },
      },
    },
    Loading: {
      invoke: {
        src: "submitActor",
        input: ({ event, context }) => {
          assertEvent(event, "submit");
          return { event: event.event, context };
        },
        onError: { target: "Error" },
        onDone: { target: "Complete" },
      },
    },
    Error: {
      on: {
        "update-username": {
          actions: assign(({ event }) => ({
            username: event.username,
          })),
        },
        submit: { target: "Loading" },
      },
    },
    Complete: {},
  },
});

With this we have all the states in place to implement the UI:

export default function Machine() {
  const [snapshot, send] = useActor(machine);

  if (snapshot.matches("Complete")) {
    return <p>Done</p>;
  }

  return (
    <form onSubmit={(event) => send({ type: "submit", event })}>
      <input
        type="text"
        value={snapshot.context.username}
        onChange={(e) =>
          send({ type: "update-username", username: e.target.value })
        }
      />
      <button type="submit" disabled={snapshot.matches("Loading")}>
        Confirm
      </button>
      {snapshot.matches("Error") && <p>Error occurred</p>}
    </form>
  );
}

Notice how the states we defined with useState and useReducer are the same encoded in the machine:

// 👇 `useState` and `useReducer` states
type State = "Editing" | "Loading" | "Error" | "Complete";

// 👇 Machine states
const machine = setup({
  /// ...
}).createMachine({
  context: initialContext,
  initial: "Editing",
  states: {
    Editing: { /** ... */ },
    Loading: { /** ... */ },
    Error: { /** ... */ },
    Complete: {},
  },
});

Some observations from the new implementations:

  • We needed to make states explicit. This is still a rather simple flow, so we are still able to read and understand the code with useState and useReducer. However, as the logic grows in complexity, state transitions will become harder to understand and maintain, since they are scattered in different places. With XState state is encoded in the machine since the beginning.

This is how the state machine we implemented looks like. This structure is explicit and encoded in the machine with XState, while it says implicit when using useState and useReducer (and most of the other state management libraries)
This is how the state machine we implemented looks like. This structure is explicit and encoded in the machine with XState, while it says implicit when using useState and useReducer (and most of the other state management libraries)

  • useState and useReducer required to restructure the code, while with XState is only a matter of adding a new state.
  • XState allows to handle even more complex scenarios (parallel states, hierarchical states, validation, and more). As new requirements are added, the logic will always be contained inside the machine. This cannot be said for useState and useReducer. Their logic will become even more scattered and harder to understand.

We start to see the benefits of XState way more clearly now, as the logic grows in complexity.

That's one of the reasons why XState is hard for beginners: simple examples and use cases are not ideal to convey the benefits.

Initially it may seem that we are only making the code more complex and verbose, but the real benefits are in the clarity of the logic.

As the logic evolves, with XState we can keep the code bug free and maintainable. We are going to appreciate this even more in the next modules.