DEV Community

Cover image for Mastering XState Fundamentals: A React-powered Guide
Ibrahim Ayuba
Ibrahim Ayuba

Posted on

Mastering XState Fundamentals: A React-powered Guide

The first article in this series discussed the core differences between the reducer and finite state machine models. In this article, we will dig into the fundamental concepts of XState using the React framework. We'll cover states, events, actions, and guards — providing a solid foundation for building programs with XState.
XState is a robust state management library that leverages the simplicity and power of finite state machines to build efficient, scalable JavaScript or TypeScript applications.
Before we begin, prior knowledge of JavaScript and React is necessary to grasp the content of this article. Familiarity with Redux or React's useReducer() hook would be beneficial but is not mandatory. Lastly, we will be using JavaScript throughout this article.
If you wish to code along, you will need XState and @xstate/react in addition to React.

npm i xstate @xstate/react
Enter fullscreen mode Exit fullscreen mode

Now that we’re on the same page, let us learn about state.

State

Reducer model

When we create apps using the reducer pattern, we need to keep all the data in one spot, called the state. For example, if we're making an API call program, it will include the status, data, and error.

const initialState = {
  status: "idle",
  data: undefined,
  error: undefined,
};
Enter fullscreen mode Exit fullscreen mode

These variables will change throughout the program's lifecycle. When the program is triggered, it transitions from idle to loading. If the API call succeeds, it assigns the returned value to the data property and changes the status from loading to success. Otherwise, it attaches the returned error message to the error property and changes the status from loading to error.

const fsmReducer = (state, action) => {
  switch (state.status) {
    case "idle": {
           //code to move to loading state when triggered.
    }
    case "loading": {
        //move to success if the api call is successful. Otherwise, move to error state.
    },
    case "success" {},
    case "error" {},
     default: {
      return state;
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

In a usual reducer model, we typically switch based on events, not states. However, in the example mentioned, we're switching based on the state, aligning with the principles of finite state machines.
In summary, the reducer model stores all application variables in a central store, while the finite state machine pattern takes a different approach.

Finite State Machines Model

The FSMs pattern distinguishes clearly between the application's status and other data. In the FSMs model, the program's status is referred to as the state, representing the qualitative aspect, while the remaining data represents the quantitative part of the application.

A program can exist in only one state at any given time and has a finite number of possible states. For instance, a light bulb switch can be either in the on or off state.

All other data concerning the application constitutes the quantitative part. For our API call logic, this could be the resolved data from the API call or error message.

Let's redefine the API call program as in the previous example, this time utilizing FSMs with XState.

import { createMachine } from "xstate";

const fetcher = createMachine({
  initial: "idle",
  context: {
    data: undefined,
    error: undefined
  }
  states: {
    idle: {
        //Some code to execute
    },
    loading: {
        //more code
    },
    success: {
        //more code
    },
    error: {
        //more code
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

In XState, states are explicitly defined, and all other data is stored in the context object.

The createMachine() function accepts the application’s logic object as an argument.

You need to specify the initial state of the application, especially if it has multiple states. In this case, its initial state is idle.

Events

Events are occurrences within the system, which can be triggered by a user action, like clicking a button, or by the program itself, such as resolving or rejecting a promise in JavaScript.

Sending Events

When a user interacts with the UI, like clicking a button or typing, we aim to capture these events and respond by executing some code. XState provides a method to capture these UI events and send them to the state machine for proper handling. Additionally, these events can carry additional dynamic information from the UI.

For example, consider a React counter machine. Whenever the user clicks a button, we want to increment the count by a number provided by the UI, 2 in this case.

import { assign, createMachine } from "xstate";
import { useMachine } from "@xstate/react";

const counterMachine = createMachine({
  context: {
    count: 0,
  },
  on: {
    ADD: {
     //code to handle the Add event
    },
  },
});

export default function App() {
  const [snapshot, send] = useMachine(counterMachine);

  return (
    <div>
      <p>count: {snapshot.context.count}</p>
      <button onClick={() => send({ type: "ADD", data: 2 })}>Add two</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

XState provides the useMachine() React hook, which returns an array containing the snapshot object and the send function. The snapshot object holds essential details about the state machine, including the context object. Meanwhile, the send function allows us to dispatch events from the UI to the machine for appropriate handling.

Next, we would see how events such as ADD are handled within the machine’s logic.

Handling Events

All state- or program-level events are defined within the on object.

For single-state programs like the counter example, the on object is directly placed on the program logic object.

For multi-state applications like the API-call program, the on object resides within individual states.

It's worth noting that even if an application has multiple states, you can still have an on object with events directly on the program logic object, similar to the counter example. When such top-level events are dispatched to the machine, they take effect regardless of the current state of the application.

const fetcher = createMachine({
  initial: "idle",
  context: {
    data: undefined,
    error: undefined
  },
  states: {
    idle: {
      on: {
        FETCH: {
          target: "loading",
        },
      },
    },
    loading: {
      on: {
        FETCHED: {
          target: "success",
        },
        CANCEL: {
          target: "idle",
        },
      },
    },
    success: {
     on: {
       RELOAD: {
         target: "loading",
       },
     },
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

The idle state allows the FETCH event, while the loading state permits the FETCHED and CANCEL events.

Each of these events is assigned an action object. In this case, the action object contains the target property, specifying the transition or the next state the machine will enter when the event is received.

If the target is not specified, the program will stay in its current state. Apart from transitions, the action object can also include actions, which we'll explore next.

Actions

In addition to transitions, actions are also responses to events. actions can include invoking a function, altering a variable within the context object, or executing asynchronous operations. Next, we’ll learn about each of these common actions in XState.

Changing a Variable

In our counter app example, we utilize the assign action to modify variables within the context object. The syntax is simple and allows us to modify one or multiple variables simultaneously.

const counterMachine = createMachine({
  context: {
    count: 0,
    otherVariable: undefined
  },
  on: {
    ADD: {
      actions: assign({
        count: ({ context, event }) => context.count + event.data,
        otherVariable: ({context, event}) => //new value
      }),
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Invoking a Function

When an event is dispatched, you might simply want to execute a function, as demonstrated in this case:

const counterMachine = createMachine({
  context: {
    count: 0,
  },
  on: {
    ADD: {
      actions: ({event}) => console.log(event)
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

The above action will merely log the event. Additionally, you can execute multiple actions sequentially by assigning an array of action functions to the actions property.

const counterMachine = createMachine({
  context: {
    count: 0,
  },
  on: {
    ADD: {
      actions: [({event}) => console.log(event),
          assign({
        count: ({event}) => event.data
            })
      ]
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Performing Asynchronous Operations

XState offers convenient methods to handle asynchronous processes using actors. In this discussion, we'll concentrate on the fromPromise actor, designed for executing promises, including API calls using fetch().

Asynchronous operations via actors have a slight distinction from other actions. While other actions are accessible on the event level within the actions object, actors (employed for asynchronous tasks in XState) are executed via the invoke object, which is only available at the state level.

import { assign, createMachine, fromPromise } from "xstate";
import { useMachine } from "@xstate/react";

const counterMachine = createMachine({
  initial: "idle",
  context: {
    data: undefined,
    error: undefined,
  },
  states: {
    idle: {
      on: {
        LOAD_DATA: {
          target: "loading",
        },
      },
    },
    loading: {
      invoke: {
        src: fromPromise(() =>
          fetch("https://fakestoreapi.com/products/category/jewelery").then(
            (res) => res.json()
          )
        ),
        onDone: {
          target: "success",
          actions: assign({
            data: ({ event }) => event.output,
          }),
        },
        onError: {
          target: "error",
          actions: assign({
            error: ({ event }) => event.error,
          }),
        },
      },
    },
    success: {},
    error: {},
  },
});

export default function App() {
  const [snapshot, send] = useMachine(counterMachine);

  return (
    <div>
      <button onClick={() => send({ type: "LOAD_DATA" })}>Load Data</button>

      {snapshot.value == "loading" && <div>Loading...</div>}

      {snapshot.value == "success" && (
        <ul>
          {snapshot.context.data.map((item) => (
            <li key={item.id}>{item.title}</li>
          ))}
        </ul>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

When the user clicks on the Load Data button, it triggers the loading state, where the invoke object is executed. We assign the fromPromise() to the src property. The fromPromise() actor requires a callback function that returns a fulfilled or rejected promise. If the promise succeeds, the onDone event is triggered; otherwise, the onError event is called.

Guards

If you need to transition to a particular state or execute certain actions based on the value of a variable within your context object, you can achieve this functionality using guards. Guards are functions that evaluate to either true or false.

Instead of assigning a single action object with transitions and actions, you attach an array of action objects. Each object in this array will include a guard function along with target and actions.

XState executes these objects serially. The first object whose guard evaluates to true will have its target and actions executed.

const counterMachine = createMachine({
  initial: "editing",
  context: {
    todo: "",
  },
  states: {
    editing: {
      on: {
        RECORD_INPUT: {
          actions: assign({
            todo: ({ event }) => event.data,
          }),
        },
        SAVE: [
          {
            guard: ({ context }) => context.todo.length > 0,
            actions: () => console.log("saved"),
          },
          {
            guard: ({ context }) => context.todo.length == 0,
            actions: () => console.log("not saved"),
          },
        ],
      },
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

In this simple todo app, the user is unable to save an empty text or todo.

Conclusion

This article explores the basics of state machines using XState. It covers the fundamentals of states, events, actions, and guards. Each of these concepts has more depth to explore, including parallel states, entry and exit actions, and input and output, all of which you learn more about in the documentation.

XState is a powerful library with comprehensive documentation. Keeping the documentation handy while building your next app with XState will be invaluable.

Top comments (0)