State Driven Development for User Interfaces (Part 3: Parallel State Machines)
Nimmo
Posted on February 4, 2018
This is the final part of a three-part series. It's probably worth reading part 1, "An Introduction", and part 2, "Finite State Machines", before reading this one. :)
You've probably already realised that, for the most part, attempting to reason about an entire application as a single state machine is likely to lead to an unmanageable number of states. The ideas we've talked about so far would still work, but you'd end up with twice the number of states just by introducing something like a pop-over menu, which doesn't feel ideal.
Let's think about the concept of parallel state machines that need to interact with each other. Going back to our door
that we built in the first part of this series, let's think about what we'd need to change in order to add an alarm
to the room that the door is in.
Our door
's states can remain as they were previously:
LOCKED
CLOSED
OPENED
And our alarm
's states will look like:
ARMED
DISARMED
TRIGGERED
So far, so simple. But we need to model the transitions between states as we did before, with the added complexity of one needing to broadcast information to the other. Let's see how that looks on a diagram:
What we've introduced to this diagram is the ability to have an action occur in one state machine based on entry into a given state in another. That is, when the door is opened, the trigger
action is fired, which sets the alarm to its TRIGGERED
state, but crucially only if it is in a state that the trigger
action can be accessed from.
The trigger
action is fired whenever the door is opened, but the door doesn't need to know whether the alarm is armed or not; that's not the door's problem. Remember, we decided in the introduction to this series that we didn't need to put unavailable transitions onto our diagram? Same thing here - trigger
will put the alarm into its TRIGGERED
state, but only if it is currently in the ARMED
state.
Hopefully, you can see how easily and cleanly this can be extended too; for example, if you wanted to make the system such that the alarm automatically armed itself when the door was locked.
Implementation
We need to add the files for alarm
first, which, unsurprisingly, won't look drastically different from the ones for our door
. If you'd like to see them, you can do so on Github.
Because we've introduced hierarchy and parallelism with our flashy new security system, I think it makes sense to organise our containers
slightly differently too.
Instead of:
containers/
alarm.js
door.js
room.js
store/
alarm.js
door.js
Let's have:
containers/
room/
sub_containers/
alarm.js
door.js
room.js
store/
room/
alarm.js
door.js
And we'll need to add something to allow one state machine to broadcast to another. After a little deliberating, we settled on adding broadcastAction
to the return value of any action creator that needed to broadcast an action to another state machine:
import {
actionTypes as alarmActionTypes,
} from './alarm';
// snip!
export
const open =
() => ({
type: actionTypes.OPEN,
broadcastAction: alarmActionTypes.TRIGGER,
});
And we've added a very small bit of custom middleware, which we're referring to as the state bridge
:
const stateBridge =
store =>
next =>
action => {
const shouldBroadcastAction =
action.broadcastAction !== undefined
if (shouldBroadcastAction) {
next({ type: action.broadcastAction })
};
return next(action);
};
export default stateBridge;
This will intercept any action that's called through Redux
, and check to see if there are any actions it needs to broadcast when it's activated, and then broadcast them if so before carrying out the original action.
We apply the above when configuring our store.
And there we have it; we now have the ability to broadcast actions from one state machine to another, we've updated our OPEN
action on our door
so that it broadcasts the TRIGGER
action from our alarm
, and we don't have to do anything extra to our alarm
to make this work, because the receiving state machine doesn't have to have any knowledge of where external actions came from (as per our state chart earlier), it just needs to know how to handle that transition when it's asked to.
Conclusion
It's easy to get carried away with bolting features on to an application without thinking too hard about the wider context. And it's pretty boring, thinking about this stuff in advance, right? I know, you just want to write some code and see something on the screen; believe me, I get that. We're intrinsically driven to build our apps by starting with the HTML; that's how the majority of front-end engineers got into development in the first place. But we've come a long way since we were just scripting animations and dynamically showing and hiding elements. As our client-side applications have advanced, so has our need to consider the types of data-driven issues previously only associated with server-side development.
But too often we find ourselves thinking about state as being auxiliary to our application, and fail to realise that it's really the single most important thing our applications have.
Any set of data can cause a singular visual state. If our application receives a set of data that doesn't match up to a single state type, then we've got an anomaly, which should be appropriately handled. The more we think of our visual output as one of a finite (and known) possible state types, the more we restrict our possibilities for errors. And with the amazing tooling we have available today, we can easily test that our states are rendered as we expect them to be.
Manage your state carefully, and I reckon you'll end up thanking yourself pretty quickly. :)
If you'd like to have a look at a fleshed-out implementation of SDD, Todd has provided a couple of examples on Github.
Posted on February 4, 2018
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
February 4, 2018