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 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
fromuseMachine
(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 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 fileinit-error
: Called when the audio player reports and error when loading the file (for example an invalidsrc
)
From 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 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 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 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)
π‘ 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 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)
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 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 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-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 π€©
Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.
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 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:
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.
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
:
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 theassign
function 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
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 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:
"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 π€©
Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.
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.matches
allows 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:
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.