A tale of an iffy codebase and the state machine that tamed it
I started my greenfield Calmly project out on pristine waters. I stood at the forefront of my boat with confidence as it sailed out to sea, thinking to myself how simple my code was going to be. But then the storms came - the if statements at first fell like a gentle rain, but quickly became a monsoon of ifs and elses. I was cracking my toe on every corner when I wanted to change something or add some new functionality. What started out as a fun adventure was quickly turning into a tale of pain and sinking ships!
Okay, I may have gotten a bit dramatic there. But you know what? Looking at a wall of if statements makes me feel dramatic. This was also entirely at odds with the purpose of Calmly - to help software engineers reduce stress and avoid burnout. This was not reducing my stress.
Then three letters popped back into my mind: FSM. No, not "Flying Spaghetti Monster" (though it couldn't have hurt to ask him for some divine intervention), I'm talking about Finite State Machines. XState is the big gun in this space for Node.js projects, so I thought I'd give the whole thing a whirl. Surely it'd be better than my iffy code I had! (Spoiler - it got worse before it got better).
The call of the finite state machine
Before we dive into the deep end, let's take a moment to understand what a finite state machine (FSM) actually is. In its simplest form, an FSM is like a really organised person with a strict daily routine. It can only be in one specific state at a time (like "sleeping", "working", or "wolfing down a block of chocolate"), and it moves between these states based on specific events or conditions.
For Calmly, this concept seemed perfect. After all, our little bot has a pretty straightforward daily routine too. Here's a basic example of how we could represent Calmly's simple check-in flow using XState:
import { createMachine, createActor } from 'xstate';
const calmlyMachine = createMachine({
id: 'calmly',
initial: 'idle',
states: {
idle: {
on: { START_CHECK_IN: 'dailyCheckIn' }
},
dailyCheckIn: {
on: { SUBMIT_CHECK_IN: 'thankingUser' }
},
thankingUser: {
on: { FINISH: 'idle' }
}
}
});
const calmlyActor = createActor(calmlyMachine);
calmlyActor.start();
// Let's take Calmly for a spin!
console.log(calmlyActor.getSnapshot().value);// 'idle'
calmlyActor.send({ type: 'START_CHECK_IN' });
console.log(calmlyActor.getSnapshot().value);// 'dailyCheckIn'
calmlyActor.send({ type: 'SUBMIT_CHECK_IN' });
console.log(calmlyActor.getSnapshot().value);// 'thankingUser'
calmlyActor.send({ type: 'FINISH' });
console.log(calmlyActor.getSnapshot().value);// 'idle'
In this example, our Calmly bot starts in the 'idle' state. When it receives a 'START_CHECK_IN' event, it transitions to the 'dailyCheckIn' state. After the user submits their check-in (triggered by the 'SUBMIT_CHECK_IN' event), Calmly moves to the 'thankingUser' state. Finally, it returns to 'idle' when the 'FINISH' event occurs.
Now, I know what you're thinking: "Gareth, mate, this looks more complicated than a few if statements!" And you're right, for this simple example, it does. But as a bot's behaviour gets more complex (and oh boy, does it get complex), this approach starts to shine brighter than a disco ball at a 70’s night rave.
The synchronous struggle: When machines have a mind of their own
Alright, so I had my shiny new state machine all set up and ready to go. I was feeling pretty chuffed with myself, thinking I'd cracked the code (pun absolutely intended) to managing Calmly's increasingly complex state. But then, like a seagull swooping in to steal your chips at the beach, reality struck.
You see, Calmly isn't just a simple bot that fires off messages and forgets about them. Oh no, it's got to do things like make API calls, write to databases, and generally wait around for stuff to happen. And here's where things got about as smooth as a gravel milkshake.
The problem wasn't that the machine was jumping all over the place. No, it was more insidious than that. The machine was taking its sweet time working through its event handling and associated state transitions, long after I'd finished the request and response handling in my Node.js app. It was like trying to herd cats – just when you think you've got them all in one place, you turn around and find half of them have wandered off to do their own thing.
Here's roughly what I was trying to do:
const calmlyMachine = createMachine({
id: 'calmly',
initial: 'idle',
states: {
idle: {
on: { START_CHECK_IN: 'dailyCheckIn' }
},
sendingDailyCheckin: {
invoke: {
src: async () => {
await sendCheckInMessage();
// Some other async operations
return 'done';
},
onDone: 'waitingForCheckinResponse'
}
},
waitingForCheckinResponse: {
// ...more states
}
}
});
// In my Node.js request handler
app.post('/check-in', (req, res) => {
const actor = createActor(calmlyMachine).start();
actor.send({ type: 'START_CHECK_IN' });
// Send response
res.send('Check-in started');
// Stop the machine and save its state
const finalState = actor.getSnapshot();
saveStateToDatabase(finalState);
});
But here's the kicker: by the time I was stopping the machine and trying to save its state, it was still in the 'sendingDailyCheckin' state! All those lovely state transitions I'd set up were still chugging along in the background, blissfully unaware that I was trying to shut down the show.
What I really wanted was a way to say, "Oi, XState! Hold your horses mate, let me know when you're done with all your fancy transitions before I try to save your state!" I needed to be able to await for an event to be fully processed, so I could then gracefully shut down the machine and tuck its contents back into the database for a nice little nap.
But XState, in all its asynchronous glory, wasn't having a bar of it. It was happily chugging along in its own little world, blissfully unaware of my synchronous desires. I found myself longing for the days of simple if-statements, where things happened one after the other, nice and predictably.
Thanks to the wonders and patience of LLMs when asked lots of silly questions again and again, I stumbled across a neat little solution - waiting for a stable state!
Eureka moment: Waiting for stable states
After countless hours of staring at my screen, muttering incantations to the coding gods, and consuming more coffee than is medically advisable, I finally stumbled upon the holy grail of XState synchronicity - waiting for stable states.
Now, what in the world is a "stable state," you ask? Well, it's not a state where your code decides to take up meditation and find inner peace. It's actually when your state machine has finished processing all its immediate transitions, settled into a state where it's not immediately going to transition again, and all its child actors have completed their work. It's like waiting for a toddler to finally sit still after a sugar rush, and for all their toys to stop moving too.
Here's the magical function that became my new best friend:
Recommended by LinkedIn
import { Actor, waitFor } from "xstate";
async function waitForStableState(actor: Actor) {
await waitFor(
actor,
(state) => {
return (
!state.hasTag("loading") &&
state.status === "active" &&
Object.values(state.children).every((child) => {
if (child === undefined) return true;
const childState = child.getSnapshot();
return (
childState.status === "done" || childState.status === "stopped"
);
})
);
},
{ timeout: 10000 },
);
}
This little beauty waits for the state machine to reach a point where:
Now, here's a crucial bit: for this to work properly, you need to add a "loading" tag to any states that invoke promise actors. It's like putting a "Wet Paint" sign on a freshly painted bench - it lets our function know that some async work is still in progress. For example:
const calmlyMachine = createMachine({
id: 'calmly',
initial: 'idle',
states: {
idle: {
on: { START_CHECK_IN: 'dailyCheckIn' }
},
sendingDailyCheckin: {
tags: ['loading'],
invoke: {
src: async () => {
await sendCheckInMessage();
// Some other async operations
return 'done';
},
onDone: 'waitingForCheckinResponse'
}
},
waitingForCheckinResponse: {
// ...more states
}
}
});
Now, let's see how this transforms our previous nightmare into a dream scenario:
// In our Node.js request handler
app.post('/check-in', async (req, res) => {
const actor = createActor(calmlyMachine).start();
actor.send({ type: 'START_CHECK_IN' });
// Wait for the machine to settle
await waitForStableState(actor);
// Now we can safely get the final state, save it, and respond
const finalSnapshot = actor.getSnapshot();
await saveStateToDatabase(finalSnapshot);
res.send('Check-in completed');
});
With this approach, we're no longer trying to herd cats or nail jelly to the wall. We're patiently waiting for our state machine to finish its business before we try to save its state or send a response.
The best part? This solution plays nicely with our Node.js server's async nature. We're not blocking the event loop; we're just politely asking XState to let us know when it's done doing its thing.
Now, I won't lie to you - implementing this wasn't all sunshine and rainbows. There were moments when I thought I'd have better luck teaching a goldfish to juggle. But once it clicked, oh boy, it was like finding the last piece of a 1000-piece puzzle that had fallen under the couch. Suddenly, everything just fit.
With this newfound power, I could finally make XState dance to my synchronous tune. Calmly's state management went from a chaotic jazz improvisation to a well-choreographed ballet. Well, maybe more like a slightly uncoordinated flash mob, but hey, progress is progress!
The state of things: Pros, cons, and pretty diagrams
Let's chat about the good, the bad, and the pretty of XState. On the plus side, this nifty tool has really cleaned up my code. The clarity it brings is impressive - I can actually understand what's going on in my app now! Testing has become much easier, and debugging feels like a breeze. The predictability is fantastic too; those confusing "how did we get here?" moments are now few and far between.
But it's not all smooth sailing with XState. The learning curve is pretty steep, and I often find myself writing more code than I initially expected. For simpler apps, it can feel like overkill. And let me tell you, once you get the hang of it, you'll be tempted to turn everything into a state machine. I caught myself considering one for my daily routine the other day!
Now, for a fun little side note - the diagrams. XState can generate these visual representations of your state machines, and you can even interact with them!
It's like having a map of your app's behaviour. Not only do they look good, but they're also incredibly useful. When I drop the mental image of my code logic and need to pick up the pieces in a hurry, I can whip out the diagram and quickly get back on track. Plus, they're great for spotting potential issues or areas for improvement. Who knew looking at app logic could be so interesting?
Here's an example of a diagram from the first simple state machine at the top of this article.
Conclusion: The Journey Begins
Well, here we are at the end of our XState adventure - or should I say, the beginning? What a ride it's been so far! From drowning in a sea of if-statements to dipping my toes into the waters of state machines, it's been a fun journey for Calmly and I.
So, was it worth it? Has XState saved me from the tangled web of conditional logic?
The jury's still out on that one, to be honest. I'm just at the start of this XState journey, and I don't know if it'll be all sunshine and roses. There have been frustrating moments, times when I questioned my sanity (ironic for a mental health app, I know), and the learning curve has been steep.
But you know what? For now, it seems to be helping. Calmly's codebase is becoming more organised and easier to reason about. I can actually visualise the flow of the app, both in my head and with those nifty diagrams. It's early days, but I'm cautiously optimistic.
Is XState the right solution for every project? Absolutely not. For simpler apps, it is totally overkill. But for something like Calmly, with its growing complexity and need for reliability, it's showing promise.
So if you find yourself drowning in a sea of if-statements, maybe give XState a go. Just remember, it's a journey - and I'm right there with you, still figuring it out. Who knows? Maybe one day I'll even create that coffee-making state machine. After all, proper caffeine management is crucial for mental health, right?