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:
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
sendfromuseMachine(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:
π‘ 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 fileinit-error: Called when the audio player reports and error when loading the file (for example an invalidsrc)
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:
π‘ 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:
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:
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:
π‘ 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":
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:
In alternative we can also right-click on the state and add an "Effect" 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:
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:
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
Editingstate.
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 dive headfirst into a topic, uncovering every hidden nook and shadow, to deliver you the most interesting insights
Not convinced? Well, let me tell you more about it
XState machine implementation
After defining the complete logic on the editor we can now export our machine and start coding!
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.
MachineParamstakes 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:
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.
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.
setupallows to define all the generic configuration of the machine (actions,actors,delays,guards,types).This makes possible to use the same
setupconfiguration 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:
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:
assign: Used to update some values inContext. These effects will be wrapped by theassignfunction provided by XState and they will returnPartial<Context>- 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
eventdirectly.
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 isvoid, 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:
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 dive headfirst into a topic, uncovering every hidden nook and shadow, to deliver you the most interesting insights
Not convinced? Well, let me tell you more about it
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:
- Defining and rendering the layout based on the current state
- 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.matchesallows to match a specific state (fully type-safe)
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:
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.
