XState, a Finite State Machine Created with JavaScript

2022. 9. 22.

XState, a Finite State Machine Created with JavaScript

I had occasionally used FSM (Finite State Machine) when implementing UIs. I applied the basic concepts using enum and conditional statements. In situations with many defined states and different possible actions depending on the state, I would likely apply FSM without hesitation. In such cases, the code tends to become complex regardless of how it's written, so making the code consistent with defined actions based on state helps reduce complexity. I came across XState on social media and roughly knew it was a JavaScript implementation of FSM. I had planned to look into it someday, and recently took the time to study it. To give you the gist, it was better than I expected, and I felt it might be sufficient to build applications using only XState without other state management tools. I plan to use it a few times in small toy projects to finalize my thoughts.

This post summarizes the basic concepts and usage of XState. Reading this before diving into the official documentation might help you grasp the concepts. If I have misunderstood anything, please feel free to email me (shirenbeat@gmail.com), and I will correct it immediately.

What is a Finite State Machine?

A finite state machine is well-defined on Wikipedia, but the formal definition might turn you off. I understood it better through books that explained it more simply.

In short, when actions need to differ based on specific states, the complexity increases significantly with the number of states. In such situations, FSM organizes complex, tangled code with a limited structure and a few constraints. You can think of it as one of the design patterns.

For example, let's say you're making a game. The character's actions should change based on the joystick buttons. Pressing the A button makes the character jump, and pressing the B button makes them attack.

const isJumping = false;

function handleInput(button) {
  if(button === 'AButton') {
    jump();
  } else if(button === 'BButton') {
    attck();
  }
}

In code, you might create a handler like this. So far, so good.

But now, if you press the attack button after the jump button, the character should perform a jump attack.

const isJumping = false;

function handleInput(button) {
  if(button === 'AButton') {
    isJumping = true;
    jump();
  } else if(button === 'BButton') {
    if (isJumping) {
      isJumping = false;
      jumpAttack();
    } else {
      attck();
    }
  }
}

We use an isJumping flag to determine the situation. Adding just one flag suddenly increases the complexity.

Now, let's add a condition that you cannot jump or attack again during a jump attack. The condition becomes more complex, right?

const isJumping = false;
const isJumpAttack = false;

function handleInput(button) {
  if(button === 'AButton') {
    if (!isJumpAttack) {
      isJumping = true;
      jump();
    }
  } else if(button === 'BButton' && !isJumpAttack) {
    if (isJumping) {
      isJumping = false;
      isJumpAttack = true;
      jumpAttack();
    } else {
      attck();
    }
  }
}

With just two states, the code becomes this complicated. As the flags representing situations increase, it will get even more complex.

Now let's change the code using FSM.

const State = {
  STANDING: 0,
  JUMP: 1,
  JUMP_ATTCK: 2,
}

let currentState = State.STANDING;

function handleInput(button) {
  switch(currentState) {
    case State.STANDING:
      if(input === 'AButton') {
        currentState = State.JUMPING;
        jump();
      } else if(input === 'BButton') {
        attck();
      }
      break;
    case State.JUMPING:
      if(input === 'BButton') {
        currentState = State.JUMP_ATTACK;
        jumpAttack();
      }
      break;
  }
}

By limiting the possible states to one at a time, even if there are multiple types of states, we lowered the complexity. We defined the possible actions based on the state. The code length actually increased, but what do you think? Isn't the code easier to read? I think so. There's a pattern. It simplifies the structure into the current state and the events (actions) that can occur accordingly, making it easier to understand the relationships between states and actions through the code.

Here's a summary of what's needed when defining an FSM:

  • The possible states are finite (predefined).
  • Only one state can be active at a time (cannot be jumping and standing simultaneously).
  • Inputs or events are sent to the machine (button press/release).
  • Each state has transitions to the next state based on input.

The FSM code implemented directly has simple functionality and lacks specific abstraction for FSM, so the external advantages might not seem significant just by looking at the code. However, using XState allows you to use FSM in a very declarative, more readable way with various features included.

Installation

You can install and use XState alone, but since I'll be writing example code using React, I'll also install the React package.

> npm i xstate @xstate/react

XState natively supports React, Vue, and Svelte, and you need to install the necessary package for each individually.

Installing the React package allows you to use XState more easily with hooks.

Defining the State Machine

Let's go through XState's concepts one by one while creating a simple shopping cart.

import { createMachine } from 'xstate';

const cartMachine = createMachine({
  states: {},
});

A state machine is defined using createMachine().

The states object defines what states this state machine can have.

Since it's a shopping cart, let's define empty and hold for now. The state is either empty or holding something.

const cartMachine = createMachine({
  id: 'cart',
  initial: 'empty',
  states: {
    empty: {},
    hold: {},
  },
});

Two states have been defined. Additionally, the initial state value initial and the machine's id have been defined. The state machine provided by XState supports state nesting and can even define complex state structures where a state machine contains another state machine. Therefore, a unique id is defined to identify the state machine.

Executing Events

Currently, only the available states are defined, but there's no definition of how states can transition. States can transition to other states through events. Let's add an event.

Inside the state node, define events by creating an object with on. You can think of it like the on prefix in onClick.

const cartMachine = createMachine({
  id: 'cart',
  initial: 'empty',
  states: {
    empty: {
      on: {
        ADD_ITEM: {
          target: 'hold',
        },
      },
    },
    hold: {},
  },
});

An event ADD_ITEM that can be executed in the empty state has been defined. The target value is the name of the state to transition to when the event occurs. That is, if the ADD_ITEM event occurs in the empty state, it transitions to the hold state. Let's create a simple React component to check this.

import { useMachine } from '@xstate/react';

// ...machine definition code omitted...

const Cart = () => {
  const [state, send] = useMachine(cartMachine);

  return (
    <div>
      <p>{state.value}</p>
      <button
        onClick={() => {
          send('ADD_ITEM');
        }}
      >
        Add Item
      </button>
    </div>
  );
};

Thanks to @xstate/react, which we installed along with XState, we can easily use XState in React.

state is an object representing the current state of the state machine. It doesn't just contain data about the state but includes various data. The text information of the current state can be obtained through state.value.

And the send() function is used to send events to the state machine.

Initially, the string "empty" is displayed. When the Add Item button is clicked, the ADD_ITEM event is triggered by send(), the state transitions, and "hold" is displayed.

Context and Actions

Currently, the state machine only has states like "holding" or "not holding," without information about the actual items being held. XState stores additional data, distinct from the state controlling the flow (like the items in the cart), in the context. Context is nothing special, just a simple object.

I liked this point the most. In React, both state and data are often managed using useState, making no distinction between them. XState clearly distinguishes between state and data. The documentation refers to them as finite state and infinite state, respectively, and also calls context "extended state," which might seem a bit complex initially.

First, let's create the context. It's easy. We'll define an array to store product names as text.

const cartMachine = createMachine<Context>(
  {
    id: 'cart',
    initial: 'empty',
    context: {
      items: [],
    },
    //...
});

Inside the state machine's options, define an object named context and declare the context data to be used within it, along with initial values.

To change or add values to the context, use actions. Actions are one of the ways XState handles side effects. They are also used to modify the context.

const cartMachine = createMachine(
  {
    id: 'cart',
    initial: 'empty',
    context: {
      items: [],
    },
    states: {
      empty: {
        on: {
          ADD_ITEM: {
            target: 'hold',
            actions: ['addItem'], //[1]
          },
        },
      },
      hold: {},
    },
  },
  {
    actions: { //
      addItem: (context, event) => {
        context.items.push(event.item);
        console.log(context.items);
      },
    },
  }
);

Define actions by creating an actions object at [2]. Note that it's defined as the second argument to createMachine. The added action is addItem. It's called an action, but it's just a regular function.

Declare an array named actions at [1] to list the actions to be executed when the ADD_ITEM event occurs.

The action function defined at [2] receives the entire context object as the first argument and the payload received when the ADD_ITEM event occurred as the second argument. Let's modify the React component to verify it works correctly.

const Cart = () => {
  const [state, send] = useMachine(cartMachine);

  return (
    <div>
      <p>{state.value}</p>
      <ul>
        {state.context.items.map((name, index) => ( //[1]
          <li key={index}>{name}</li>
        ))}
      </ul>
      <button
        onClick={() => {
          send('ADD_ITEM', { item: `item${Date.now()}` }); //
        }}
      >
        Add Item
      </button>
    </div>
  );
};

Access the context via state.context at [1]. Iterate over items and render them as <li>.

At [2], pass the event payload as the second argument to the send() function. You can pass the DOM event object directly to this object to handle the event within the machine. There are examples of this in the XState official documentation, and they looked quite plausible.

Self-Transitions

After transitioning from empty to hold due to the ADD_ITEM event, no matter how many times you press the button, items are not added. This is because no events are defined for the hold node. Let's copy and paste from the empty node.

// ...
hold: {
  on: {
    ADD_ITEM: {
      actions: ['addItem'],
    },
  },
},
// ...

Now, items are added every time the button is pressed.

You might have noticed that unlike empty, the ADD_ITEM event in hold doesn't have a target for transition. This isn't a mistake; it's intentional. It's not that there's no transition, but a "self-transition," meaning it transitions from hold back to hold. In the hold state, the shopping cart remains in the hold state no matter how many items are added, until you purchase or intentionally empty the cart. So, each time the ADD_ITEM event occurs, it re-enters the hold state. The number of items in the context items increases, but the state remains the same.

Simply Updating Context with assign

XState provides several convenience functions for defining state machines. Among them, the assign() function allows you to implement context updates simply.

Let's look again at the addItem() action that updates the item array.

{
  actions: {
    addItem: (context, event) => {
      context.items.push(event.item);
    },
  },
}

It uses the array's push() method to directly modify the items array. In XState, which provides a purely functional API, it seems like immutability should be maintained. While you could use the spread operator in the current state, using assign() allows you to write the code in a slightly more declarative and clean form.

{
  actions: {
    addItem: assign({
      items: ({ items }, event) => [...items, event.item],
    }),
  }
}

Pass an object as an argument to the assign() function to update the existing context values. The addItem action needs access to the context and event, so it was defined as a function. However, if that's not the case, you can use values or literals directly. For example, an initialization action would be like that. Since this action will be needed later anyway, let's add it now.

{
  actions: {
    addItem: assign({
      items: ({ items }, event) => [...items, event.item],
    }),
    resetItems: assign({
      items: [],
    }),
  },
}

Define the RESET_ITEMS event in the hold node, and add a button that triggers this event to the React component to see it in action. When the RESET_ITEMS event occurs, the next state to transition to should be empty. Add target: 'empty'.


// machine
RESET_ITEMS: {
  target: 'empty',
  actions: ['resetItems'],
},

// component
<button
  onClick={() => {
    send('RESET_ITEMS');
  }}
>
  Reset Item
</button>

Guards

Now let's add one more action to create a feature for deleting items from the shopping cart.

The action can be easily implemented using filter().


// ...
removeItem: assign({
   items: ({ items }, event) => items.filter((item) => item !== event.name),
}),

I deliberately didn't destructure event to distinguish it from the context.

And add the REMOVE_ITEM event to the hold node. Without the event, the action won't run.

hold: {
  on: {
    // ...
    REMOVE_ITEM: {
      actions: ['removeItem'],
    },
  }
}

When the REMOVE_ITEM event occurs, it self-transitions back to the hold node. So, target is not needed.

For the React component, we can add a delete button that triggers the REMOVE_ITEM event when rendering the items.

// ...
{state.context.items.map((name, index) => (
  <li key={index}>
    {name}
    <button onClick={() => send('REMOVE_ITEM', { name })}>X</button>
  </li>
))}

Up to this point, deleting product items one by one works fine. However, as some might have already noticed, there's a bug in this code. Even when all items are deleted one by one, the state remains hold, even though there are no items left.

The concept that can be used here is "Eventless Transition," also known as always. It refers to immediately transitioning to another state based on the situation when transitioning to a specific state. You can think of it as an event that runs automatically whenever a transition occurs.

The function that determines the situation can be defined separately and reused; such a function is called a guard. Define guard functions by creating a guards object in the same object where actions were defined. Similar to actions, the context is passed as the first argument to the guard function.

The guard we need is isEmpty(), a function that checks if the size of items is 0.

{
  actions: {
    // ...
  },
  guards: {
    isEmpty: ({ items }) => items.length === 0
  }
}

Now, the task is to use always to check isEmpty() whenever transitioning to hold, and if true, immediately transition to empty.

hold: {
  always: {
    target: 'empty',
    cond: 'isEmpty',
  },
  on: {
    // ...
  }
}

Currently, there's only one transition condition, but you can define always as an array to use multiple conditions.

For example, like this:

hold: {
  always: [
    {
      target: 'empty',
      cond: 'isEmpty',
    },
    {
      target: 'full',
      cond: 'isFull',
    }
  ],
  on: {
    // ...
  }
}

Furthermore, "conditional transitions" using guards are not limited to always; they can also be used in events. Similar to always, define the event as an array and just add the guard.

Although completely unrelated to the shopping cart, let me show an example from the official website briefly.

on: {
  OPEN: [
    { target: 'opened', cond: 'isAdmin' },
    { target: '.error', cond: 'shouldAlert' },
    { target: '.idle' }
  ]
}

Since the array executes from the first element, the first condition has higher priority. So, if both isAdmin and shouldAlert conditions are true, it will transition to opened. And like the last element in the array, you can define a default transition without cond to transition to .idle.

Requesting Purchase from the Server

Now that we've stored items in the shopping cart, we need to request a purchase from the server. XState provides various ways to integrate the state machine with external systems, including asynchronous operations. You can use the Actor model or utilize Services. Here, we'll use services to implement an asynchronous operation that calls an API.

We'll add a state called purchasing, call the API from that state, receive the result, and finally transition to the completion state done.

empty -> hold -> purchase -> (API CALL) -> done

The transitions generally follow this order. Additionally, there's a transition from hold back to empty.

I mentioned using services to call the API. Ultimately, it involves using Promises, so it's not too difficult to understand. Services can use Promise-based or callback-based functions, and even other machines can be used as services. You can even use Rxjs Observables. Here, we'll use fetch method with Promise.

Create a new node purchasing under the hold node.

hold: {
  // ...
},
purchasing: {
  invoke: {
    id: 'purchasing',
    src: (context) => postPurchase(context.items),
    onDone: {
      target: 'done',
      actions: ['purchased'],
    },
    onError: {
      // ...
    }
  },
},

Services are defined using invoke. Here, src can use a Promise, machine, Observable, or callback function to communicate with the service target. Since we'll be using a Promise, the postPurchase() function will return a Promise, right? I'll omit the function implementation. Assume it uses the fetch() function internally to send the items list to the server. postPurchase() returns the Promise returned by fetch().

Being able to use Promises means there's a way to receive the Promise's result. onDone defines the transition when the Promise resolves, and onError, as you might expect, defines the transition when the Promise rejects.

onDone has purchased registered as an action to execute; I'll explain this in the last part.

The done state node is simple.

purchasing: {
  // ...
},
done: {
  type: 'final',
  entry: ['resetItems'],
},

type can define the type of the state node. Currently, the state machine is simple. However, state nodes can nest other state nodes within them and can operate child states in parallel. There are various types accordingly. Refer to the official documentation for cute diagrams and easy understanding.

The type of the done state node is final. This means this state is the final state. That is, the state machine has achieved its goal and terminated.

Reusing State Machines

When the final node is reached and the state machine terminates, the onDone callback is executed. This allows the component using the state machine to receive the event and handle necessary business logic. In our shopping cart example, we could close the cart or integrate it with other UI elements.

By registering a final callback with onDone and extending the state machine's actions or guards, you can increase reusability by handling different business logic per component.

In a React component, the onDone callback can be registered using the service instance.

const [state, send, service] = useMachine(cartMachine);

The third argument of useMachine is the service instance. For now, think of it as the instance of the state machine.

useEffect(() => {
  const listener = () => console.log('done');

  service.onDone(listener);

  return () => service.off(listener);
}, []);

Use service.onDone() to register an event listener. The registered listener executes when transitioning to a state node with type: 'final'.

Also, when using useMachine, you can extend the state machine's actions or guards:

const [state, send] = useMachine(cartMachine, {
  actions: {
    purchased: (context) => {
      console.log(context.items.length);
    },
  },
});

This way, you can use the second argument object when calling useMachine to extend actions and guards. When we defined the service for the API call earlier, we registered an action called purchased in onDone. This action wasn't defined when creating the machine, but we can define it when using the machine like this, effectively adding an action to the already defined machine. You can also overwrite existing actions, so think of it as a kind of override. Not only actions but also guards can be extended or injected in the same way.

Conclusion

So far, we've used the basic concepts of XState to create a simple shopping cart. XState allows you to write code where the separation of data and state, the flow of states, transition conditions, and the execution structure of side effects are clearly visible at a glance. The example here was very simple, but its strengths would be more apparent in applications or UIs with complex structures. Rather than using it for the entire application initially, trying it out for managing complex UI states in a part of the application, while keeping its usage hidden from the outside, might be a good way to verify its potential and allow for easy rollback if needed. The content I've summarized covers very basic concepts. XState has many useful features. Its TypeScript support is also excellent.

XState seems to be a tool that is less known than its utility warrants. I encourage you to review it during this opportunity.

Here is the complete example code:

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

import { useEffect } from 'react';

const postPurchase = (cart) =>
  // Let's assume we called the API with fetch()...
  new Promise((res) => {
    setTimeout(() => {
      res(true);
    }, 2000);
  });

const cartMachine = createMachine(
  {
    id: 'cart',
    initial: 'empty',
    context: {
      items: [],
    },
    states: {
      empty: {
        on: {
          ADD_ITEM: {
            target: 'hold',
            actions: ['addItem'],
          },
        },
      },
      hold: {
        always: {
          target: 'empty',
          cond: 'isEmpty',
        },
        on: {
          ADD_ITEM: {
            actions: ['addItem'],
          },
          RESET_ITEMS: {
            target: 'empty',
            actions: ['resetItems'],
          },
          REMOVE_ITEM: {
            actions: ['removeItem'],
          },
          PURCHASE: {
            target: 'purchasing',
          },
        },
      },
      purchasing: {
        invoke: {
          id: 'purchasing',
          src: (context) => postPurchase(context.items),
          onDone: {
            target: 'done',
            actions: ['purchased'],
          },
        },
      },
      done: {
        type: 'final',
        entry: ['resetItems'],
      },
    },
  },
  {
    actions: {
      addItem: assign({
        items: ({ items }, event) => [...items, event.item],
      }),
      resetItems: assign({
        items: [],
      }),
      removeItem: assign({
        items: ({ items }, event) => items.filter((item) => item !== event.name),
      }),
    },
    guards: {
      isEmpty: ({ items }) => items.length === 0,
    },
  }
);

const Cart = () => {
  const [state, send, service] = useMachine(cartMachine, {
      actions: {
          // Example of extending/overriding actions at useMachine level
          purchased: (context) => {
            console.log('Purchase successful for items:', context.items.length);
          },
        },
  });

  useEffect(() => {
    const listener = () => console.log('Machine reached final state (done)');

    service.onDone(listener);

    return () => service.off(listener);
  }, [service]); // Add service as dependency

  return (
    <div>
      <p>Current State: {state.value}</p>
      <p>Items: {state.context.items.length}</p>
      <ul>
        {state.context.items.map((name, index) => (
          <li key={index}>
            {name}
            <button onClick={() => send('REMOVE_ITEM', { name })}>X</button>
          </li>
        ))}
      </ul>
      <button
        onClick={() => {
          send('ADD_ITEM', { item: `item${Date.now()}` });
        }}
      >
        Add Item
      </button>
      <button
        onClick={() => {
          send('RESET_ITEMS');
        }}
        disabled={state.context.items.length === 0 && state.value === 'empty'}
      >
        Reset Items
      </button>
      <button
        onClick={() => {
          send('PURCHASE');
        }}
        disabled={state.value !== 'hold' || state.context.items.length === 0}
      >
        {state.matches('purchasing') ? 'Purchasing...' : 'Purchase Items'}
      </button>
      {state.matches('done') && <p>Purchase complete!</p>}
    </div>
  );
};

export default Cart;
♥ Support writer ♥
with kakaopay

Creative Commons LicenseThis work is licensed under a Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License.

shiren • © 2025Sungho Kim