Let's say we have get a feature request. We want this feature to process data in steps, and we know what the shape of the data will be at each step. We want each step to conduct whatever action/processing upon the data that it needs to do, and then engage the next step in the process. The order of the steps is important, and we want to limit bugs by ensuring that only certain actions can be done at certain steps. We want a data structure that will have several states, move through different states in predictable ways, only engage a singular state at once, and only allow certain actions said state is active.
What I just described is the perfect use case for a state machine! State machine's are data structures that do pretty much exactly what I described above. They have many states, but only engage in one state at a time. They move through these states in predictable ways. When they are in these states, there are different actions that we can take to interact with them.
So that's a bit abstract, so let's code out a simple example. Let's imagine a stoplight with an attached red light camera.
function stopLightMachine() {
const states = {};
const actions = {};
return {
state: 'red',
states,
...actions,
}
}
So we know that we will have three properties. First, we have the state, which is the state our State Machine is currently in. For the sake of this exercise, it always starts at red. We also have states and actions, which we don't know what they will be yet but we will need them. Actions will be methods that we can use to act upon state machine, and states will define functionality of these actions at different states. Let's fill out our actions.
const actions = {
dispatch(actionName) {
const action = states[this.state][actionName];
if (action) return action.apply(this);
},
changeStateTo(newState) {
this.state = newState;
},
};
First up we have changeStateTo, which is a method that we use to, you guessed it, change the state of our state machine. Dispatch is what we will use to actually engage in different stateful actions. Essentially, we will simply ask the state machine to do something. If it is possible, then the state machine will do it. If not, then nothing will happen.
Now all we have to do it build out our states. The state is what color the light is, and switching between these states is predictable; red -> green -> yellow -> red. There is no other order in which we can switch state, so state changes are predictable.
const states = {
red: {
start() {
this.changeStateTo('green');
console.log('changing state to green')
this.dispatch('continue');
}
},
yellow: {
continue() {
setTimeout(() => {
console.log('changing state to red')
this.changeStateTo('red');
}, 3000)
}
},
green: {
continue() {
setTimeout(() => {
console.log('changing state to yellow')
this.changeStateTo('yellow');
this.dispatch('continue');
}, 10000);
}
},
};
In this case we define some methods for each state. They're all a little different but they aren't too difficult to understand. For the most part, they change the state of our state machine to the next desired state, and then dispatch the next desired action to our machine. When we are at red and dispatch start, we run the start function under the red state. When this function is done it changes the state to green and dispatches continue, which executes the action we want in the green state. In this way, we will move through the different states of our machine and complete the cycle we set out to program in the first place.
We are missing one thing though, since we said we wanted to run a red light camera on our stoplight. In this case, all we have to do is add an action to our red state.
red: {
start() {
this.changeStateTo('green');
console.log('changing state to green')
this.dispatch('start');
},
driveThrough() {
console.log('haha ticket camera go brrrrrr');
},
},
When someone runs the red light, the ticket camera should fire. However, it shouldn't do anything on yellow and green. Since our dispatch will find the function and only execute it if it exists, we are safe from having our camera accidentally fire during a yellow or green light. The action doesn't exist on those states, so there is no way for the state machine to accidentally run the function even if the action is dispatched. This is one of the best parts about state machines; they allow us to encapsulate functionality in various steps and ensure that desired functionality only happens at particular moments.
Let's take a look at all of this wired up and working.
const stoplight = stopLightMachine();
console.log(stoplight.state) // red
stoplight.dispatch('driveThrough') // haha ticket camera go brrrrrr
stoplight.dispatch('start') // run start in red; changing state to green
console.log(stoplight.state) //green
stoplight.dispatch('driveThrough') // nothing happens, this state does nothing for this action
// after a few seconds state will be changed to yellow, and then back to red
Now theres a lot more functionality and protections we could add to this, but that goes a bit beyond this as an example. The point was to show a simple state machine that moves through states and encapsulates functionality into those steps, which we definitely achieved. However, let's take a look at a real world example of state machines in Javascript that many of us use everyday: Promises.
Promises are a great part of the Javascript language, but are also something that a lot of people coming into the language have trouble grasping. They are a bit confusing once you first look at them, but many people recognize them as an essential and powerful part of the JS after working with them for a bit. In fact, once you start to consider them as state machines, Promises make a lot of sense in how they function!
So let's try to think of Promises in terms of a state machine. Promises in JS have three states: pending, fulfilled, and rejected. When a Promise is first created, it is always in the pending state. The Promise hasn't done anything yet, and will not do anything for us until it is acted upon. Once a Promise is acted upon and executed, it is considered to be settled, and will go into either the fulfilled or the rejected state depending on whether the action it was taking was successful or not. Once a Promise has been fulfilled or rejected can we do something with the information, until then we must wait for it to finish.
So we have a state machine with three, mutually exclusive states. Two of those states follow the first one. We cannot act upon the machine in its first state: we must wait until we have entered into one of those two following states can we act upon the data we receive. Let's try to code it!
NOTE: Yes, I am aware that the following is an incomplete form of a promise implementation, I didn't even use the new keyword! I left out a lot because the important part isn't whether or now I know how to implement a promise, the important part is that we can write it as a state machine.
const SamplePromise = (asyncWork) => {
let status = 'pending';
let thenCallbacks = [];
let value;
handleReject = () => {};
const actions = {
then(callback) {
thenCallbacks.push(callback);
return this;
},
catch(callback) {
handleReject = callback;
},
}
return {
...actions
}
}
So a couple of things to note. First of all, we define what actions our interface is capable of and return that out of our function. We expose then and catch, or what we will be doing when our promise is fulfilled or rejected. We return this out of our then function so that we can chain thens and always make sure we are returning an interface our code knows how to act upon, that way we don't get 'cannot read property then of undefined' after our first resolution. How do we go from having an interface to actually making anything execute?
const SamplePromise = (asyncWork) => {
let status = 'pending';
let thenCallbacks = [];
let value;
handleReject = () => {};
const actions = {
then(callback) {
thenCallbacks.push(callback);
return this;
},
catch(callback) {
handleReject = callback;
},
}
const resolver = (val) => {
status = 'fulfilled';
value = val;
thenCallbacks.forEach((fn) => {
value = fn(value);
})
};
const rejector = (val) => {
status = 'rejected';
handleReject(val);
};
asyncWork(resolver, rejector);
return {
...actions
}
}
We define two new functions here: resolver and rejector. This is where a lot of the action actually happens. In our resolver function we are going to update our promise status to fulfilled, store our value, and iterate over our then callbacks and execute them passing our subsequent values down through each one. Our rejector is a little simpler, it's just going to pass the error that got received down to our handleReject function.
SamplePromise((resolve, reject) => {
setTimeout(() => {
resolve('RESOLVE');
}, 1000);
})
.then((response) => `**FIRST THEN STEP** : ${response}`)
.then((response) => console.log(response, ' MORE ADDED!'));
// logs: **FIRST THEN STEP** : RESOLVE MORE ADDED!
SamplePromise((resolve, reject) => {
setTimeout(() => {
reject('PLEASE NO');
}, 1000);
})
.catch((err) => console.log('ERR: ', err));
// logs: ERR: PLEASE NO
We have subsequent then functions and a working catch on rejection! Thinking about Promises from this angle is a nice way to simplify them down in your head; they have three discrete states and need to be acted upon to move to the next states. However, they can do async work and then act upon those acquired values, or gives us errors when something doesn't work out.