How we implemented a media library with XState and React

Hadas Shveky-Teman
Kaltura Technology
Published in
11 min readJul 9, 2023

--

In the current world of frontend development, it is almost a must to think about how to handle state management in our applications. Usually, we would think about it in the architectural part of the project or feature.

I currently work as a FE developer in Kaltura, a company that provides a platform for video hosting, streaming, publishing, analytics and more. At Kaltura, we mostly deal with videos and building experiences around them that provide value for our customers. One of the features we were working on was a management tool that gives users the ability to upload and manage video files. The requirement was to have a dynamic media library that shows the different states for each uploading file, allowing multiple file uploads at a time. I was fortunate enough to plan, build and implement this feature in our React app.

Media Page

In this blog post, I will show our take on this challenge and how we solved it with XState library. I assume you know React pretty well, and have some knowledge of the state management world and the need for it. Maybe you heard of XState or played with it a bit, but if not, it’s fine. However, it is preferred if you know the basic concepts of a finite state machine, since the whole idea of XState is based on it.

Getting to know XState

Once I started to think about this task, I had one of those famous coffee break talks with a colleague. She had experience in her team writing something similar and not using XState, so she described the pain points they encountered in the project. She suggested I use XState for state management when implementing the feature in my team.

Not knowing XState, I was about to use the same old state management library that we were already using. But something in her enthusiasm encouraged me to check it out, so I sat down and read about it. Right from the start, it sounded like a great fit for this situation. Little did I know it would be so easy to implement and maintain.

Why use XState

XState is a well-known and commonly used state management library. It implements the idea of a finite state machine in the JS ecosystem, giving the application the ability to transfer from state to state as a result of an action or event invoked from user interaction or another state of the system.

With XState, you can be certain that the managed object is in a specific state at a specific time. You can know what state got it there and which state will be next. Another benefit is that Stately, the creator of XState, supplies a visualizer tool for your state machine, which can help you define the machine, visualize its states, and communicate it to others.

In my opinion, compared to other state management libraries (such as Redux or react query), XState is more suitable for small subsets of application flows or designated features inside a large application. This can be argued, but I think it is not robust enough to store large amounts of data or flows, or to manage the entire application state. You can achieve the latter by creating a few state machines, each in charge of a certain part of the state. But for that, you need a strong and well documented architecture, and it becomes more complex to manage and maintain.

My rule of thumb: if you can describe your problem in a finite state machine diagram, XState is for you.

In our situation of building the media library, it was really fitting — the user uploads a video file, which then goes through different states. Consider this finite state machine for the flow of an uploading item:

Uploading file infinite machine diagram

Let’s get down to business

Building the machines

So, just for the sake of aligning our thoughts, a machine in XState is just a JS file that mainly holds:

  • A unique name/id
  • An initial state
  • A context — that’s the machine’s internal state or memory of the machine
  • States that the machine can get to
  • Actions/events that the machine can react to and decide what to do

First of all, we need a parent machine to rule them all. Due to the requirement to have multiple files uploading simultaneously, we need a few machines running concurrently, each representing a file that goes through the state machine in the diagram above.

The parent machine, however, is meant to manage the entire context of the uploading environment. It creates a child machine for each uploading item and holds them in a list stored in its context.

The definition of the parent machine will be as follows:

import {createMachine} from "xstate";

export const mediaManagerMachine = createMachine({
id: 'mediaManagerMachine',
initial: EParentMachineStates.idle,
context: {
machines: []
},
})

Here we have the id of the machine, the initial state (idle) and its context, which is simply the list of child machines (initially empty).

These are the states of the machine and their definition:

states: {
[EParentMachineStates.idle]: {
on: {
[EParentMachinesEvents.TURN_ON]: EParentMachineStates.active,
}
},
[EParentMachineStates.active]: {
on: {
[EParentMachinesEvents.TURN_OFF]: EParentMachineStates.done,
}
},
[EParentMachineStates.done]: {
type: 'final'
},
},

On idle it can be TURN_ON and switched to active state; on active it can be TURN_OFF and switch to done state, which is a final state (i.e. no state can come after it). Don’t be confused with XState syntax — the word on here is the notation for ‘do something when an event occurs.’

The TURN_ON / TURN_OFF events will be sent from the media component when mounted/unmounted, since we need the machine to be active for the whole lifetime of the page that hold this feature. The only purpose of the parent machine is to be active and ready to add child machines (which I will show next).

Now, we need a child machine structure to hold every uploaded item. This is the machine that will actually go through the state transfers from our diagram:

import {createMachine} from "xstate";

export const mediaMachine = createMachine({
id: 'mediaMachine',
initial: EChildMachineStates.idle,
context: {
itemId: '',
mediaUploadData: {},
},
})

This machine is also initiated in the idle state, this time, the child machine state. The context of the child holds its unique itemId and the upload data, such as the file itself. These are empty at first and will be populated once a file starts to upload.

The parent machine creates a child machine with the relevant data for each uploading item. On the parent machine, we define an event and the action to perform when this event occurs, which is to add a new child machine to the list in context.

Here we can see the ADD_MACHINE event, where we spawn a new machine, add it to the list, and assign the updated list to context:

on: {
[EParentMachinesEvents.ADD_MACHINE]: {
actions: assign({
machines: (context, event) => {
return [
...context.machines,
{
itemId: event.payload.itemId,
ref: spawn(
mediaMachine,
{
name: `mediaMachine_${event.payload.itemId}`,
sync: true
}
)
}
]
}
})
}
},

The spawn function gets the entity to create (this is the id we gave our child machine — mediaMachine). Notice that the spawned machine should have a unique name to distinguish the machines that will run in parallel.

After the child machine is created, the parent machine can communicate with each child machine and send events to it. For that, we add the SEND_TO_MACHINE event, on which it finds the required machine from the list using its unique id and passes the event to it:

[EParentMachineStates.active]: {
on: {
...,
[EParentMachinesEvents.SEND_TO_MACHINE]: {
actions: (ctx, event) => {
const machine =
ctx.machines.find(machine => machine.itemId === event.payload.itemId);
if (machine?.ref) {
machine.ref.send({...event, type: event.payload.eventName})
}
}
}
}
}
}
}

Defining external services to invoke

First, handling the upload itself. Our application has a component that listens to the machine’s list. Once a machine is added (in idle state), it sends a START_UPLOAD event to it, which is handled this way in the child machine:

states: {
[EChildMachineStates.idle]: {
on: {
[EChildMachinesEvents.START_UPLOAD]: {
target: EChildMachineStates.uploading,
actions: assign({
itemId: (context, event) => event.payload.data?.mediaUploadData.itemId || '',
mediaUploadData: (context, event) => event.payload.data?.mediaUploadData || {}
})
},
}
}
}

We can see here that when a machine is in idle state, if it gets the START_UPLOAD event, it goes to uploading state (that’s the target), and assign to the context the id of the item and data of the media file.

In the state diagram above, the item can go through different states: uploading, processing, and ready. These state transfers are automatic (not user invoked), so we need to define external services to handle them. A service is basically a function that receives data, does a certain action, and returns a result that is either successful or failed in the form of a promise.

You can also invoke other types of services (Callbacks, Observables, Machines), but I won’t cover these here.

In our solution, we have two services that invoke a promise:

  • Uploading a file — in the service ‘doUpload’, we call the UploadMedia function to handle the file upload by calling an internal Kaltura API that uploads the file itself to our servers. The service handles this task and waits for an answer. We can define the next state that it’ll go to onDone (processing) or onError (uploading_failure):
[EChildMachineStates.uploading]: {
invoke: {
id: 'doUpload',
src: (ctx, event) => UploadMedia(event.payload.data?.mediaUploadData || undefined),
onDone: EChildMachineStates.processing,
onError: EChildMachineStates.uploading_failure
}
}
  • Processing the file — once a file is uploaded successfully it immediately goes through a processing stage on the server, where it gets transpiled. In the service ‘doGetMediaStatus’, we call the GetMediaStatus function, which calls Kaltura API again, this time to get the item’s status on the server.

    The way this specific API is built makes us do polling to check the status and emulate a promise resolved/rejected. But once returned, we know if the file is ready (onDone - goes to ready) or if it’s failed (onError - goes to processing_failure):
[EChildMachineStates.processing]: {
invoke: {
id: 'doGetMediaStatus',
src: (ctx, event) => GetMediaStatus(ctx.itemId, ctx.mediaUploadData),
onDone: EChildMachineStates.ready,
onError: EChildMachineStates.processing_failure
}
}

Handling deleting and removing a child machine

Another action that we were requested to support was deleting a media file, an action invoked by the user. To support this capability, we added the handling of the DELETE event to the relevant states (ready, uploading_failure and processing_failure), transferring the machine to deleted state:

[EChildMachineStates.ready]: {
on: {
[EChildMachinesEvents.DELETE]: EChildMachineStates.deleted,
}
},
[EChildMachineStates.uploading_failure]: {
on: {
[EChildMachinesEvents.DELETE]: EChildMachineStates.deleted,
}
},
[EChildMachineStates.processing_failure]: {
on: {
[EChildMachinesEvents.DELETE]: EChildMachineStates.deleted,
}
},
[EChildMachineStates.deleted]: {
type: 'final'
},

It is important to note another action — removing a child machine. When an item is deleted (has reached its final state), it is no longer needed; therefore, it can be removed altogether. If we expect large amounts of file uploading in the same session, we should probably implement this as well. Otherwise, it can cause large memory usage and performance issues for no reason.

This can be achieved by two other cool features of XState — sending an event from the child machine to the parent machine and machine actions. We can add an entry to the deleted state, which means doing something when entering the state. In this case: an action named sendRemoveMachine:

[EChildMachineStates.deleted]: {
entry: EChildMachineActions.sendRemoveMachine,
type: 'final'
},

Then we add an actions section in the child machine with the sendParent action, to send an event of type REMOVE_MACHINE with the payload of the item id to remove:

actions: {
[EChildMachineActions.sendRemoveMachine]: sendParent((ctx) => ({
type: EParentMachinesEvents.REMOVE_MACHINE,
payload: {
itemId: ctx.itemId
}
}))
}

Also, we add the REMOVE_MACHINE event on the parent machine to catch it in active state, stopping the child machine and removing it from the list in the context:

[EParentMachineStates.active]: {
on: {
...,
[EParentMachinesEvents.REMOVE_MACHINE]: {
actions: assign({
machines: (ctx, event) => {
const machineRef =
ctx.machines.find(machine => machine.itemId === event.payload.itemId)?.ref;
if (machineRef) {
machineRef.stop();
return ctx.machines.filter(machine => machine.itemId !== event.payload.itemId);
}
return ctx.machines
},
})
}
}
},

Connecting the dots — using the state to control the UI of a React application

To use the XState library in React, we use an additional package called @xstate/react, which is the official XState way of working with React. It gives us some sugar coating and nice methods, which utilize React’s features such as hooks and React context. We need a couple of players for the architecture:

  • A MachineManager (just a custom hook) that can access the parent machine using the useMachine hook. It can also access the state of a child machine through the parent’s context:
import {useMachine} from "@xstate/react";
import {mediaManagerMachine} from "../state-machine/media-management-state-machine";

export const useMediaManagementMachinesManager = () => {
const [state, send, service] = useMachine(mediaManagerMachine);

const sendToMachine = (event: any, itemId: string, data?: any) => {
if (service) {
service.send({
type: EParentMachinesEvents.SEND_TO_MACHINE,
payload: {
itemId: itemId,
eventName: event,
...(data && {
data: {...data}
})
}
});
}
}

const getStateOfMachine = useCallback(
(itemId: string) : State<EChildMachineStates> | null => {
const machineRef =
state.context.machines.find((machine: any) => machine.itemId === itemId) || null;
return machineRef ? (machineRef as any).ref.state : null;
},[service]);
}
  • A context provider that holds the processing items list with all their info (id, filename, file, etc.), it listens to changes in the list and sends a START_UPLOAD event once an item is added (i.e. a machine is spawned). Also, by using the manager hook, it exposes an API to actions about the machines, such as getting their state or deleting a machine:
import {useMediaManagementMachinesManager} from "./use-media-management-machines-manager";

export const MediaManagementStateProvider = ({children}: {children?: React.ReactElement}) => {
const [processingList, setProcessingList] = useState<ProcessingItem[]>([]);
const {sendToMachine, getStateOfMachine} = useMediaManagementMachinesManager();

useEffect(() => {
processingList.forEach(item => {
if (stateOfItem(item.itemId)?.matches(EChildMachineStates.idle)){
if (item.mediaUploadData.activeUploadData) {
sendToMachine(EChildMachinesEvents.START_UPLOAD, item.itemId, {mediaUploadData: item.mediaUploadData});
}
}
})
}, [processingList]);

const stateOfItem = (itemId: string) : State<EChildMachineStates> | null => {
return getStateOfMachine(itemId);
}

const deleteItem = (itemId: string) => {
sendToMachine(EChildMachinesEvents.DELETE, itemId);
setProcessingList(prev => (prev.filter(item => item.itemId !== itemId)));
}
}

Then the application follows this sequence diagram:

Uploading file sequence diagram

What we achieved

  • A working feature that complied with the product and designers requirements.
  • A codebase that is easy to maintain and understand, and any addition or change from the product side (and God knows there were some) was implemented easily and integrated into the solution.
  • Experience with XState library — the learning curve was a bit longer than other libraries, but once understood, it was flexible, scalable, and easy to adjust to our needs.

Can we do even more?

There are many ways XState can be used. In this blog post, I introduced just some of the capabilities and extensibilities it has by showing how we use and implement the state machines to fit our needs.

More extensions to this solution can include having the option to cancel a file upload using a canceled state, or storing other data in the machine context and reflecting it in the UI (such as dynamic uploading percentage). Also, to have the option to initiate a child machine in a different state other than uploading (for instance, if the browser tab was refreshed and the file is already in the processing phase).

Hopefully, I left you with a taste for exploring this library more and using it in your projects going for production. For us, it’s been working great 🙂

--

--

Hadas Shveky-Teman
Kaltura Technology

After 15 years of web development, Im a Senior FE dev, OOB thinker and a problem solver. Started my way in BE and switched to FE where I found my true passion.