How to Manage Workflow Automation With XState and React

Tristan Bhagwandin
Apploi
Published in
8 min readFeb 10, 2023

XState makes managing workflow automation simpler and easier by visualizing state machines. In this post, I’ll go over how this can work, and why you might want to try it.

XState for Workflow Automation
XState for Workflow Automation

About Workflow Automation

Workflow automation removes the need for human input to complete repetitive tasks. This makes work faster and more reliable.

It leverages a series of conditional statements (if else) to activate tasks. When certain actions satisfy these conditions, deeper-level tasks and conditionals are then activated. The culmination of these constitutes a full end-to-end flow.

The Problem: Complex Workflows

The more conditional depth and actions a workflow supports, the bigger and more cryptic it becomes. The best analogy I can give is understanding how to get around in an unfamiliar city. You can explore the individual buildings and neighborhoods (look at the code, run the product) but there’s too much ground to cover. You can also get directions from someone who lives there (tribal product knowledge) but that takes time — and how can you be sure the information presented is correct? Really, what you need is a good map.

At Apploi, a rapidly-growing healthcare hiring platform, our workflows go deep. Documents, texts and emails, interviews, and status changes are all triggered by automation. So deep, in fact, that none of us know the entire workflow landscape by heart. (*Well… one extremely meticulous QA engineer did. You can learn more about him at the end of this article.)

Apploi Workflows Example
Apploi Workflows Example

This presents a serious scaling problem. As we append workflow-related features, the entire application becomes harder to decipher, more prone to breakages from unknown side effects, and slower to develop and test. To solve this, we need a way to easily understand the inner workings of the application.

The Solution: Managing Workflows With XState

This is where XState and Finite State Machine architecture comes into play. They create a visual of our workflow application — in other words, they provide a map.

You can learn more about XState and its visualizer Stately here. At the time of this writing, XState is the only React state management tool that can visualize state transitions. It’s quite the departure from more common state management tools (Redux comparison) so there is a steep(ish) learning curve.

I’ll try to mitigate some of this based upon my experience, but if you just want to see the code, visualizer, and prototype, just skip to the bottom.

How I Managed Workflow Automation With XState and Stately

To help explain exactly how XState can simplify managing workflows, I’ll take you through every step I followed while completing this work at Apploi.

Mapping it out

To better understand our current workflow application, I chose to map it out manually. This was VERY tedious, but critical for exposing permutations, patterns, and potential improvements.

Tedious map, a necessary evil
Tedious workflow mapping, a necessary evil

Taxonomy can be very limiting when it comes to workflow architecture. In our case, a few word changes opened up a range of possibilities via “Is Now” vs “Has Been.” But more on that later…

A more scalable workflow structure and focus

With the map in front of me, I chose to focus on the Document Workflow because its states were common to many workflows. Getting this right would mean getting several other workflows right. But first we needed some acceptance criteria.

Visualizeable and scalable with no funky side-effects

Ability to create new workflows and propagate from data

Record workflow actions in the correct order

Fully editable and undoable

Creating a State Machine in the Visualizer

As an architect who lives by visuals, I couldn’t wait to start working in XState’s visualizer, Stately. I began by picking apart several state machine examples provided by XState’s documentation. It seemed that I needed a single machine, running nested machines (compound states) in sequence.

To target different pathways, I started using XState’s context (similar to React’s context). I assigned contextual data via XState actions and used them in XState conditional statements (conds).

A few things I noticed while creating this visualization:

  • My conditional statements were easier to follow and flatter-looking.
  • XState actions are not affected by conditionals. They fire immediately.
  • XState targets are the main mechanism for traveling between states. Unique state identifiers make it easy to get around.
  • You can nest machines within machines and they will all share the context of the parent machine.
  • If an event is tied to a state, the application must be “in” that state for the event to happen. This makes it not only clear but safe.
  • This is more code than I normally write for seemingly trivial things.

This rough machine was where I initially arrived. Click around for yourself and hit reset if you get stuck.

First Attempt Document Workflow
First Attempt Document Workflow (click to view on Stately)

The next step was translating my state machine over to a working prototype in React.

Translating State Machines to React Web

Getting up and running in React was not difficult. I copied XState’s recommended recipe and modified it to console log the current state and context for every state change.

I opted to skip over any data fetching states and default them to the success resolution state. Lazy, yes. Critical right now, no. Neither was filling out my Typescript definitions, as you’ve probably noticed.

Sending Metadata in Events

Next, I began to pass along metadata with my events. Whereas previously I assigned arbitrary data within the machine itself, I now wanted to assign it properly, via selection (dropdown, checkboxes, etc).

I realized that leveraging more of this metadata could DRY up my code. Instead of having two unique events, I could make a single event and target states based on the incoming event data.

chooseSync: {
initial: "chooseType",
states: {
chooseType: {
on: {
HAS_CHANGED: {
actions: assign((context: any) => ({
controls: {
...context.controls,
...{ sync: true },
},
})),
target: "#adddocuments.documentStatuses",
},
HAS_BEEN: {
actions: assign((context: any) => ({
controls: {
...context.controls,
...{ sync: false },
},
})),
target: "#adddocuments.documentStatuses",
},
},
},
},
},
}

~ versus ~

chooseSync: {
on: {
CHOOSE_SYNC: {
actions: assign((context: any, event: any) => ({
controls: {
...context.controls,
...{ sync: event.sync },
},
})),
target: "#adddocuments.documentStatuses",
},
},
}

All seemed fine until I tried to simulate events in Stately. Instead of having an easy-to-click-through interface, manual data entry was now required to activate states. This was annoying to say the least.

I backtracked to having multiple unique finite events and relegating the metadata to infinite contextual assignments.

Managing Insertion Order

My next task was managing the insertion order. The Document Workflow needed to chain multiple “then” actions for single-use only.

To understand why that’s important, consider the following:

“If the cat is hungry then give it milk and give it food

“If the cat is hungry then give it food and give it milk

These statements aren’t exactly equal. Despite achieving the same result, the actions were inserted in a different order. Preserving this order maintains workflow cohesion.

We could also run into issues with statements like:

“If the cat is hungry then give it food and give it food

This statement includes an action used multiple times. We currently do not support this.

To better support the sequential nature of workflows, I changed the overarching data structures. Instead of relying upon arrays and objects, I replaced them with maps and sets.

Maps are akin to objects, but with an insertion order. Sets, meanwhile, are like arrays, but with no duplicate values and no index. Maps and Sets share the same interface (has, get, set) which allows for easy retrieval and assignment.

At few things I noticed at this time:

  • Although writing state machines seemed like more code for trivial things, the actual implementation code was a fraction of what it would normally be. It was much easier to write and read.
  • I was having a good time. I never enjoyed working with other state management tools because they confused me with imperative gotchas.

I now had a barely-working React prototype. It could create new workflows and generate existing workflows with context.

Making it Editable

To modify any state, I first had to reactivate it. By creating edit events at the root of the machine, I could activate any state without the constraint of being in a particular state.

Since my state targets were all static, modifying a state would activate the next state in the sequence. If the “next states” were already resolved, they would get reactivated unnecessarily. This resulted in a poor experience by creating needless double-work.

To skip over any resolved next states, I modified the state targets by pointing them to a single Epsilon state (eventless transition) I painstakingly named “nextState.”

nextState: {
id: "nextState",
always: [
{
cond: (context: IContext) => !context.map.has("document"),
target: "#documents.ofType"
},
{
cond: (context: IContext) => !context.map.has("async"),
target: "#documents.chooseSync"
},
{
cond: (context: IContext) => !context.map.has("documentStatus"),
target: "#documents.documentStatus"
},
...
]
}

With its target management in place, the Epsilon returned the correct next state in all cases.

To modify diverging states, I first had to unset several cascading states.

We support both synchronous and asynchronous pathways, but only the async path includes a timing state. This is where “Is Now” vs “Has Been” taxonomy comes into play.

For example:

“If the cat is now asleep then…”

“If the cat has been asleep for 9 hours then…”

When switching from sync to async and vice versa, any resolved next states become obsolete. This is because the resulting cascade branches off in a new direction.

Diverging Cascade (sync vs. async)
Diverging Cascade (sync vs. async)

To unset the obsolete next states, I created a helper that removes everything following a cursor within a map or set. Since we use maps and sets in context to track the sequence, we can unset entire cascades.

The final piece was the ability to undo. After finding a code snippet shared by David Khoursid (creator of XState and Stately), I introduced an array stack to keep a historical record in context.

With each sequential map assignment, I pushed a copy of the map into the stack. I could then reassign the map to reproduce the previous state.

And there you have it — a visualizable prototype and all of the associated code.

Document Workflow Sandbox
Document Workflow State Machine Final
Document Workflow (click to view on Stately)

After barely-immersing myself in XState, I feel like its ability to visualize state is game-changing. The learning curve is more than worth it for that reason alone. It’s also declarative. You control everything about your state along with the side effects. What you see is what you get and ONLY what you get — no surprises, no gotchas. For this weary-eyed engineer, that’s a breath of fresh air.

Special thanks to Robert Penner for showing me the ropes of XState and David Khourshid for creating a state management tool I actually like.

*Viktor Kutikov was our meticulous QA Engineering lead and friend. He passed away from a heart attack at the age of 36. This article is dedicated to him.

--

--