What are Finite State Machines and how can you use them in React to make complicated logic and UIs easier to grasp? In this article we’ll set out to provide an answer to this question by building a video player in React.
When I started to build the video player, I first thought about wanting to know if it was playing
or paused
. OK, I can use a boolean for that, right? But, while the video is loading, it’s not really playing
or paused
yet… it’s loading
. Now I had two boolean values. What if it couldn’t load the video? What happens when it reaches the end of the video? You can see how something seemingly straightforward becomes harder to model.
Read on to see how XState by David K. Piano can help us model this complicated state in React, clearly defining the flow from one state to another.
The final version of the code referenced in this article can be found here.
What Is a Finite State Machine?
In the introduction I mentioned different “states” that our video player could be in:
- loading: The initial state which occurs while we’re waiting for the video to load.
- ready: Loading was successful.
– paused: Video playback is currently paused.
– playing: Video is currently playing.
– ended: The video has reached the end of the track. - failure: For whatever reason, the loading of the video failed.
I have listed six different states our video player can be in. Notice how it is a finite number (six), and not an infinite number of potential values? Now you know where the Finite
of Finite State Machine
comes from.
A Finite State Machine defines the possible states that our app (or portion of our app) can be in, and how it transitions from one state to another.
What you’ve just seen above is the visual representation of the state machine for the video player we’ll be building.
Defining States and Transitioning Between Them
Let’s start to look at the code that defines the video state machine. It all starts with a large object that’s passed to Machine
, where we define an id
for the state machine, the initial
state it should be in, followed by all the possible states.
const videoMachine =Machine({
id:"video",
initial:"loading",
states:{
loading:{
on:{
LOADED:{
target:"ready",
actions:["setVideo"]},
FAIL:"failure"}}// additional states}});
You may have noticed that I only placed a single state here for now, called loading
, and that’s so we can explain a few additional concepts before moving on. On the loading
state we have an on
attribute which is an object:
{"LOADED":{"target":"ready","actions":["setVideo"]},"FAIL":"failure"}
This object defines all the possible events that the loading
state is prepared to receive. In this case we have LOADED
and FAIL
. The LOADED
event defines a target
, which is the new state to be transitioned to when this event occurs. We also define some actions
. These are side effects, or in simple terms, functions to call when this event occurs. More on these later.
The FAIL
event is simpler, in that it simply transitions the state to failure
, with no actions.
Context
Real-world applications aren’t made up of only finite states. In our video state machine, we actually have some additional data to keep track of, such as the duration
of the video, how much time has elapsed
, and a reference to the actual video HTML element.
In XState, this additional data is stored in the context.
const videoMachine =Machine({// ...
context:{
video:null,
duration:0,
elapsed:0},// ...}
It starts out with some initial values, but we’ll see how to set and modify these values via actions below.
Events and Actions
Events are how to transition your state machine from one state to another. When using XState within a React app, you’ll most likely end up using the useMachine
hook, which allows you to trigger events via the send
function. In the below code we are triggering the LOADED
event (which is available on the loading
state), and we’ll pass some additional data to this event.
send("LOADED",{ video: ref.current });
The send
function in this case is called within the onCanPlay
event which comes with the video
element.
exportdefaultfunctionApp(){// Setup of ref to video elementconst ref = React.useRef(null);// Using the video state machine within React with useMachine hookconst[current, send]=useMachine(videoMachine,{
actions:{ setVideo, setElapsed, playVideo, pauseVideo, restartVideo }});// Extract some values from the state machine contextconst{ duration, elapsed }= current.context;return(<divclassName="container"><video
ref={ref}
onCanPlay={()=>{send("LOADED",{ video: ref.current });}}
onTimeUpdate={()=>{send("TIMING");}}
onEnded={()=>{send("END");}}
onError={()=>{send("FAIL");}}><sourcesrc="/fox.mp4"type="video/mp4"/></video>{/* explanation of this code to come later */}{["paused","playing","ended"].some(subState =>
current.matches({ ready: subState }))&&(<div><ElapsedBarelapsed={elapsed}duration={duration}/><Buttonscurrent={current}send={send}/><Timerelapsed={elapsed}duration={duration}/></div>)}</div>);}
The setVideo
action uses a function called assign
from XState which allows you to update individual properties of the context
. We’ll use this event as an opportunity to copy the ref
to the video element over to the context, along with the video duration.
const setVideo =assign({
video:(_context, event)=> event.video,
duration:(_context, event)=> event.video.duration
});
Conditional Rendering Based on State Value
We’ve seen bits and pieces of the video state machine, but let’s take a look at it in its entirety. In the list of possible states, the ready
state has three sub-states (paused
, playing
, ended
), which is why you find it nested. This is referred to as hierarchical state nodes. In the state machine, we have defined all of the states, their events, and which actions are called for each event. If you’d like to refer back to the diagram to make sense of this, it is available here.
const videoMachine =Machine({
id:"video",
initial:"loading",
context:{
video:null,
duration:0,
elapsed:0},
states:{
loading:{
on:{
LOADED:{
target:"ready",
actions:["setVideo"]},
FAIL:"failure"}},
ready:{
initial:"paused",
states:{
paused:{
on:{
PLAY:{
target:"playing",
actions:["setElapsed","playVideo"]}}},
playing:{
on:{
TIMING:{
target:"playing",
actions:"setElapsed"},
PAUSE:{
target:"paused",
actions:["setElapsed","pauseVideo"]},
END:"ended"}},
ended:{
on:{
PLAY:{
target:"playing",
actions:"restartVideo"}}}}},
failure:{
type:"final"}}});
Our video player should show the “Pause” button when the state is {ready: 'playing'}
, and otherwise should be the “Play” button. Within the Buttons
controller, we can control this using if statements along with the current.matches
function. which allows us to match the current value of the state machine.
constButtons=({ current, send })=>{if(current.matches({ ready:"playing"})){return(<buttononClick={()=>{send("PAUSE");}}>
Pause
</button>);}return(<buttononClick={()=>{send("PLAY");}}>
Play
</button>);};
Conclusion
By thinking in terms of states and how our code transitions from one state to another via the events it receives, we’ve been able to model the complex logic of a video player in a way that makes it easier to reason about. If you’d like to hear more from David, the creator of the XState library, it’s worth listening to a podcast with Kent C. Dodds that he did recently, where they talk in detail about state machines and their relationship to music.