Getting started with XState and Effect - Audio Player

Sandro Maglione

Sandro Maglione

Web development

Learn how to use XState together with Effect to implement a state machine for an audio player:

  • How to use the Stately visual editor to define the state machine
  • How to implement the state machine in code using Effect
  • How to use the machine in a React component

Stately editor

The first step to implement any application using XState is the Stately Editor.

The major benefit in implementing State Machines with Stately is that you can visualize and simulate their behavior, even before working on the concrete implementation

The editor has many features to model any kind of machine and logic. Here we will focus on how to model a machine for an audio player.

Initial state

Every machine has a single entry state called initial state.

In the editor the initial state is marked with a bold circle and arrow:

The initial state of the machine. This state is always present and unique in any state machineThe initial state of the machine. This state is always present and unique in any state machine

In our audio player the initial state is called Init.

Events

An Event is the only way to switch from a state to another.

Events are triggered from a React component using send from useMachine (more details below).

Think of events has the equivalent of functions in a React component or actions from Redux:

/// All the logic inside the component πŸ™…β€β™‚οΈ
export default function App() {
  /// Event πŸ‘‡
  const onPlay = () => {
    // ...
  }
  
  return (
    <button onClick={onPlay}>Play</button>
  );
}
 
 
/// Logic defined inside the machine using XState πŸ†
export default function App() {
  const [snapshot, send] = useMachine(machine);
  
  return (
    <button onClick={() => send({ type: "play" })}>Play</button>
  );
}

An event is represented by an arrow from on state to another. You can add an event by selecting a state and clicking any of the 4 boxes around it:

Select a state and hover on any of the boxes around it. You can then click to add an event to transition to another stateSelect a state and hover on any of the boxes around it. You can then click to add an event to transition to another state

πŸ’‘ Important: States and events are the building blocks of any state machine.

Make sure to have a clear understanding of state and events before thinking about all the other concepts (actions, entry/exit, context)

From our Init state we can trigger 2 events:

  • loading: Called when the audio player (<audio>) starts loading the mp3 file
  • init-error: Called when the audio player reports and error when loading the file (for example an invalid src)

From Init we have 2 possible events, represented by 2 outgoing arrows in the editorFrom Init we have 2 possible events, represented by 2 outgoing arrows in the editor

We will trigger these events later on when implementing the react component (using send):

<audio
  src="audio.mp3"
  onError={() => send({ type: "init-error" })}
  onLoadedData={() => send({ type: "loading" })}
/>

Actions

Side effects can be executed in an event when transitioning from one state to another. These are called Actions.

Actions are attached to events to execute some logic.

Actions in the editor are represented by a thunder icon. We can select any event and click on the Action button to add an action inside it:

A "loading" event that contains a "onLoad" action. You can add actions to events (even more than one) by clicking "Action" after selecting the eventA "loading" event that contains a "onLoad" action. You can add actions to events (even more than one) by clicking "Action" after selecting the event

πŸ’‘ Important: actions represent the core of the machine logic. We are going to implement actions using Effect.

Final states

Some states may not have any outgoing event. These states are called Final States.

A final state represents the completion or successful termination of a machine.

If we send an init-error in our machine, it means that it is not possible to play the audio. Therefore we end up in a final state called Error:

The "init-error" event moves the machine to the "Error" state, which is marked as finalThe "init-error" event moves the machine to the "Error" state, which is marked as final

Final states in the editor are marked by a bordered square inside the state box. A state can be marked as final from the options that that appear on top of it when selected:

Select a state to open its options an mark it as finalSelect a state to open its options an mark it as final

Parent states

After the audio is successfully loaded the machine enters an Active state. The Active state in our machine is called a Parent state:

When the audio loaded successfully we enter the core of the audio player logic. This logic is contained inside an "Active" state (Parent state)When the audio loaded successfully we enter the core of the audio player logic. This logic is contained inside an "Active" state (Parent state)

πŸ’‘ The state machine itself is a parent state. It is called the Root State, and it’s always active.

The Active state acts as a "nested machine". Inside it we have another initial state Paused that becomes active when we enter the Active parent state.

We can add child states to any state (therefore making it a parent state) by right-clicking on the state and selecting "Add child state":

Right-click on any state to make it a parent state by adding a child state inside itRight-click on any state to make it a parent state by adding a child state inside it

Entry/Exit actions

Actions can be triggered also when entering or exiting a state.

This is similar to "on mount"/"on dismount" in a react component: we want to execute some logic when entering/exiting a certain state

This works the same as adding actions to an event. When we select a state the same "Action" option appears on top of it. When we click on it we add a new entry action:

Select any state and click on "Action" to add an entry action (you can add multiple actions)Select any state and click on "Action" to add an entry action (you can add multiple actions)

In alternative we can also right-click on the state and add an "Effect" from the options:

Right-click and add an entry or exit action from the optionsRight-click and add an entry or exit action from the options

In our audio player we want to pause the audio every time we enter the Paused state, so we add an onPause entry action to it.

Context and Self-transitions

The <audio> element executes onTimeUpdate when the audio is playing. We can access the currenTime value to keep track of the current time of the audio player.

We add a new currentTime to the machine Context.

Context represent the internal state of the machine

The context is added from the side panel menu when clicking the "Context" option:

On the top-right you can click the "Context" button to open the side panel to add the contextOn the top-right you can click the "Context" button to open the side panel to add the context

We need to update currentTime when the machine is in the Playing state and a new onUpdateTime is triggered. We can achieve this by adding a Self-transition to the Playing state:

Triggering the "time" event executes the "onUpdateTime" action while still remaining in the "Playing" state. This is called a Self-transitionTriggering the "time" event executes the "onUpdateTime" action while still remaining in the "Playing" state. This is called a Self-transition

The onUpdateTime is responsible to update the currentTime inside context.

Executing the time event will not update the current state, which will still remain Playing.

πŸ’‘ Note: A similar pattern can be used for a form state.

For example, every time the user edits some text we can add a self-transition while still remaining inside an Editing state.


This is all we needed to model our audio player machine.

You can view and play around with the full machine on the Stately Editor here.

You can read the XState documentation to learn about other features of state machines offered by XState: parallel states, guards, input, output, persistence, and more.

There is more.

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 600+ readers.

XState machine implementation

After defining the complete logic on the editor we can now export our machine and start coding!

You can export the code implementation of the machine by clicking "Code" to open the left panel from the editorYou can export the code implementation of the machine by clicking "Code" to open the left panel from the editor

The exported code encodes the machine logic that we defined on the editor (context, initial state, states and state transitions, events, actions):

import { createMachine } from "xstate";
 
export const machine = createMachine(
  {
    context: {
      audioRef: "null",
      currentTime: 0,
      trackSource: "null",
      audioContext: "null",
    },
    id: "Audio Player",
    initial: "Init",
    states: {
      Init: {
        on: {
          "loading": {
            target: "Loading",
            actions: {
              type: "onLoad",
            },
          },
          "init-error": {
            target: "Error",
            actions: {
              type: "onError",
            },
          },
        },
      },
      Loading: {
        on: {
          loaded: {
            target: "Active",
          },
          error: {
            target: "Error",
            actions: {
              type: "onError",
            },
          },
        },
      },
      Error: {
        type: "final",
      },
      Active: {
        initial: "Paused",
        states: {
          Paused: {
            entry: {
              type: "onPause",
            },
            on: {
              play: {
                target: "Playing",
              },
              restart: {
                target: "Playing",
                actions: {
                  type: "onRestart",
                },
              },
            },
          },
          Playing: {
            entry: {
              type: "onPlay",
            },
            on: {
              restart: {
                target: "Playing",
                actions: {
                  type: "onRestart",
                },
              },
              end: {
                target: "Paused",
              },
              pause: {
                target: "Paused",
              },
              time: {
                target: "Playing",
                actions: {
                  type: "onUpdateTime",
                },
              },
            },
          },
        },
      },
    },
    types: {
      events: {} as
        | { type: "end" }
        | { type: "play" }
        | { type: "time" }
        | { type: "error" }
        | { type: "pause" }
        | { type: "loaded" }
        | { type: "loading" }
        | { type: "restart" }
        | { type: "init-error" },
      context: {} as {
        audioRef: string;
        currentTime: number;
        trackSource: string;
        audioContext: string;
      },
    },
  },
  {
    actions: {
      onPause: ({ context, event }) => {},
      onPlay: ({ context, event }) => {},
      onLoad: ({ context, event }) => {},
      onError: ({ context, event }) => {},
      onRestart: ({ context, event }) => {},
      onUpdateTime: ({ context, event }) => {},
    },
    actors: {},
    guards: {},
    delays: {},
  },
);

XState with Typescript

I defined a MachineParams helper type to more easily type events.

MachineParams takes an object with the events as keys and the event parameters as values.

This is not required, but it makes the events easier to type and read.

MachineParams will convert the object into an event type:

types.ts
export type MachineParams<A extends Record<string, Record<string, any>>> =
  keyof A extends infer Type
    ? Type extends keyof A
      ? keyof A[Type] extends ""
        ? { readonly type: Type }
        : { readonly type: Type; readonly params: A[Type] }
      : never
    : never;
/**
type Events = {
  readonly type: "event1";
  readonly params: {
      readonly param: string;
  };
} | {
  readonly type: "event2";
}
*/
type Events = MachineParams<{
  event1: { readonly param: string };
  event2: {};
}>;

I then defined 2 types: Context and Events.

machine-types.ts
export interface Context {
  readonly currentTime: number;
  readonly audioRef: HTMLAudioElement | null;
  readonly audioContext: AudioContext | null;
  readonly trackSource: MediaElementAudioSourceNode | null;
}
 
export type Events = MachineParams<{
  play: {};
  restart: {};
  end: {};
  pause: {};
  loaded: {};
  loading: { readonly audioRef: HTMLAudioElement };
  error: { readonly message: unknown };
  "init-error": { readonly message: unknown };
  time: { readonly updatedTime: number };
}>;

setup machine types and actions

The recommended approach to define machines in XState is to use the setup method.

setup allows to define all the generic configuration of the machine (actions, actors, delays, guards, types).

This makes possible to use the same setup configuration to implement machines with different states and transitions.

Inside setup we define types and actions. We will then chain the createMachine method to use this setup to implement the machine:

export const machine = setup({
  types: {
    events: {} as Events,
    context: {} as Context,
  },
  actions: {
    onPlay: // ...
    onPause: // ...
    onRestart: // ...
    onError: // ...
    onLoad: // ...
    onUpdateTime: // ...
  },
}).createMachine({

Implement actions using Effect

We use Effect to implement all the actions of the machine.

For each action in the machine we define a corresponding method that returns Effect:

effect.ts
export const onLoad = ({
  audioRef,
  context,
  trackSource,
}: {
  audioRef: HTMLAudioElement;
  context: AudioContext | null;
  trackSource: MediaElementAudioSourceNode | null;
}): Effect.Effect<never, OnLoadError, OnLoadSuccess> => // ...
 
export const onPlay = ({
  audioRef,
  audioContext,
}: {
  audioRef: HTMLAudioElement | null;
  audioContext: AudioContext | null;
}): Effect.Effect<never, never, void> => // ...
 
export const onPause = ({
  audioRef,
}: {
  audioRef: HTMLAudioElement | null;
}): Effect.Effect<never, never, void> => // ...
 
export const onRestart = ({
  audioRef,
}: {
  audioRef: HTMLAudioElement | null;
}): Effect.Effect<never, never, void> => // ...
 
export const onError = ({
  message,
}: {
  message: unknown;
}): Effect.Effect<never, never, void> => // ...

Each method will get the input parameters from the machine and execute some logic ("effects" πŸ’πŸΌβ€β™‚οΈ).

In our project we can have 2 types of effects:

  1. assign: Used to update some values in Context. These effects will be wrapped by the assign function provided by XState and they will return Partial<Context>
  2. Side effect: Execute some logic without returning anything. These effects will have a return type of void

Define actions inside setup

The recommended approach to have full type-safety is to define the actions implementation independently from events:

Actions should receive the required values from events as parameters instead of accessing event directly.

In the onUpdateTime action for example we want to update the value of currentTime in context. We therefore use assign.

Instead of accessing the time event directly we define a required updatedTime parameter:

onUpdateTime: assign(({ event }) => ({
  // Don't access `event` directly
  // - No type safety πŸ™…β€β™‚οΈ
  // - Not generic πŸ™…β€β™‚οΈ
  //
  // type: "play" | "restart" | "end" | "pause" | "loaded" | "loading" | "error" | "init-error" | "time"
  currentTime: event.type,
})),
 
// βœ… Add required parameters instead
onUpdateTime: assign((_, { updatedTime }: { updatedTime: number }) => ({
  currentTime: updatedTime,
}))

We can then provide the parameters from the machine state transition with full type-safety:

export type Events = MachineParams<{
  play: {};
  restart: {};
  end: {};
  pause: {};
  loaded: {};
  loading: { readonly audioRef: HTMLAudioElement };
  error: { readonly message: unknown };
  "init-error": { readonly message: unknown };
 
  time: { readonly updatedTime: number }; // πŸ‘ˆ `time` event has an `updatedTime` parameter (type safe)
}>;
 
/** ... */
 
time: {
  target: "Playing",
  actions: {
    type: "onUpdateTime",
    // πŸ‘‡ Extract `updatedTime` from the `time` event (type safe)
    params: ({ event }) => ({
      updatedTime: event.params.updatedTime,
    }),
  },
}

Side effect returning void

The onPause action for example does not need to update the context, but instead it executes some logic without returning any value.

onPause requires the audioRef (from context) and executes the pause method from HTMLAudioElement.

Make sure to manually type the return type as Effect.Effect<never, never, void> to make sure the return type is void, all errors are handled and all dependencies are provided.

export const onPause = ({
  audioRef,
}: {
  audioRef: HTMLAudioElement | null;
}): Effect.Effect<never, never, void> =>
  Effect.gen(function* (_) {
    if (audioRef === null) {
      return yield* _(Effect.die("Missing audio ref" as const));
    }
 
    yield* _(Console.log(`Pausing audio at ${audioRef.currentTime}`));
 
    return yield* _(Effect.sync(() => audioRef.pause()));
  });

We can then call .runSync to execute the effect inside the machine action by providing audioRef from context:

actions: {
  onPause: ({ context: { audioRef } }) =>
    onPause({ audioRef }).pipe(Effect.runSync),

Event transition from action

In the case of the onLoad event we want the action to manually execute the loaded or error events after the loading is completed.

Every action has access to a self parameter that allows to manually call send:

onLoad: assign(({ self }, { audioRef }: { audioRef: HTMLAudioElement }) =>
  onLoad({ audioRef, context: null, trackSource: null }).pipe(
    Effect.tap(() => 
      Effect.sync(() => self.send({ type: "loaded" })) // πŸ‘ˆ Success
    ),
    Effect.tapError(({ message }) =>
      Effect.sync(() => self.send({ type: "error", params: { message } })) // πŸ‘ˆ Error
    ),
    Effect.map(({ context }) => context),
    Effect.catchTag("OnLoadError", ({ context }) => Effect.succeed(context)),
    Effect.runSync
  )
)

By doing this the machine will transition to the Loading state until the action will manually execute either the loaded or error event:

"onLoad" will start the loading process until we manually send a "loaded" or "error" event at the end of the action"onLoad" will start the loading process until we manually send a "loaded" or "error" event at the end of the action


This is all for the actions implementation. You can read the full implementation using Effect in the open source repository:

There is more.

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 600+ readers.

Use machine in React component

The last step is actually using the machine in a react component.

By using XState we defined all the logic inside the machine.

This allows us to write real react components, which should be only responsible for:

  1. Defining and rendering the layout based on the current state
  2. Sending events in response to user actions

@xstate/react provides a useMachine hook that accepts the machine we just implemented.

useMachine gives us access to 2 values:

  • snapshot: Contains information about the machine (value, context, matches, and more)
  • send: Function used to send events in response to user actions

snapshot.matches allows to match a specific state (fully type-safe)

App.tsx
import { useMachine } from "@xstate/react";
import { machine } from "./machine";
 
export default function App() {
  const [snapshot, send] = useMachine(machine);
  return (
    <div>
      {/* Use `snapshot.value` to access the current state πŸ‘‡ */}
      <pre>{JSON.stringify(snapshot.value, null, 2)}</pre>
 
      <audio
        crossOrigin="anonymous"
        src="https://audio.transistor.fm/m/shows/40155/2658917e74139f25a86a88d346d71324.mp3" 
        onTimeUpdate={({ currentTarget: audioRef }) => send({ type: "time", params: { updatedTime: audioRef.currentTime } })}
        onError={({ type }) => send({ type: "init-error", params: { message: type } })}
        onLoadedData={({ currentTarget: audioRef }) => send({ type: "loading", params: { audioRef } })}
        onEnded={() => send({ type: "end" })}
      />
 
      <p>{`Current time: ${snapshot.context.currentTime}`}</p>
 
      <div>
        {snapshot.matches({ Active: "Paused" }) && (
          <button onClick={() => send({ type: "play" })}>Play</button>
        )}
 
        {snapshot.matches({ Active: "Playing" }) && (
          <button onClick={() => send({ type: "pause" })}>Pause</button>
        )}
 
        {snapshot.matches("Active") && (
          <button onClick={() => send({ type: "restart" })}>Restart</button>
        )}
      </div>
    </div>
  );
}

This is it!

Our app is now complete and ready:

Final audio player: play, pause, restart. You have full control over your audio 🎧Final audio player: play, pause, restart. You have full control over your audio 🎧

The code is easy to read and maintain:

  • All the logic defined inside the machine (using a visual editor πŸͺ„)
  • Actions are all defined in a separate file using Effect (independent from the machine and fully type-safe πŸ”₯)
  • Rendering and sending events is the responsibility of the react component (logic-free ✨)

This is the ultimate dream setup for any frontend project πŸš€

If you are interested to learn more, every week I publish a new open source project and share notes and lessons learned in my newsletter. You can subscribe here below πŸ‘‡

Thanks for reading.

πŸ‘‹γƒ»Interested in learning more, every week?

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 600+ readers.