This is an unusual use case but one that a Statechart is uniquely fitted to solve. We had a form which handled the nuances of entering Firewall rules. Specifically it allowed a user to enter networking information. With networking we had three distinct families and four possible types of protocols Depending on the combination of these two values we wanted to show a different form. Some hide erroneous fields (like address, port numbers, etc.) and others needed special fields (like ICMP type and codes). To add further complication some protocols depend on the family and visa-versa. To better illustrate this relationship here is a possible user interaction:
- User chooses the IP4 address family
- This allows the user to enter an IPv4 network address (for example
192.168.0.1
) - But then they realize they really wanted to use the ICMPv6 protocol.
- By selecting this we no longer can use an IPv4 network address and instead need an IPv6 address. This means the address family needs to react.
This simple edge case explodes in complexity the more possible combinations between the to. The math works out as 12 possible views of the form with 144 possible transitions between them (3! * 4!
). Once we realize that the act of changing one value has the potential to change the other and effect other form elements we know there are side effects that come from transitioning states. That means there are potentially 144 opportunities for side effects and 144 opportunities for bugs and missed edge cases. This is too much to manage in a simple component using a set of booleans and fancy computed property footwork. Or least it was for me.
To help solidify this abstract situation an example of one side effect is that of the address field. The form in question for legacy design reasons could only handle one input for the network address. When a user enters an IPv4 address but changes the family to IPv6 we want to clear out the address. But when they go back to IPv4 we want the value previously entered to repopulate. The acts of caching and populating that field are side effects that need to be defined in a deterministic way so that their triggering can be controlled based on the different states we are in and states we are going to.
Here is a UML state diagram showing the form and the side effects it manages:
Statechart
Demo
Implementation
Notice above how things are related. The address family matches the protocol when ICMP is selected and visa versa. The None option is disabled appropriately and if you enter values for the address it toggles between the two families. This is all managed from the statechart illustrated above.
For simplicity the above Demo and the code examples here rely on direct DOM use in some cases where the logic was simpler to do so. In Ember though there are so many ways to approach a solution and I don't want these examples to discourage you from using XState or Statecharts in your own way. These are just an example on how I ended up implementing things.
For this tutorial I will place all the relevant files into one component. Just know this is not the only way to organize things.
app/components/
└── network-form
├── -state-machine.js
├── component.js
├── styles.scss
└── template.hbs
State Machine
I added the XState machine definition to its own file -state-machine.js
which exports a createMachine()
function.
View the XState machine definition.
Notice that some of the internal logic (actions, guards) specific to the machine itself is defined as part of this function. This allows the utility of this function to be used in multiple contexts. For example this same code can be used in both the Ember component and also in the single HTML vanilla demo. It can also be used to visualize the machine in XState's visualizer.
The actions/guards/services that are not implemented are specific to the application the machine is being used in. In this case we can add those implementation details with the .withConfig()
method (see below).
Component (JavaScript)
Using this pattern for the component we will want to track the joined state. Our state machine will have two parallel states and we need to track that. I found it was clearer in the template if the tracking was done in one property and the CSS handled the layout logic based on that. In this case we will track this.state
and expose the cumulative states using XState's toStrings method:
get stateStrings() {
return this.state.toStrings().join(' ');
}
The next concern is to link up the template specific logic using the .withConfig()
method when we create the component's machine.
let actions = {
resetIP4Address: ctx => this.address.value = ctx.ip4Address,
resetIP6Address: ctx => this.address.value = ctx.ip6Address,
cacheIP4Address: assign({ ip4Address: () => this.address.value }),
cacheIP6Address: assign({ ip6Address: () => this.address.value }),
setFamilyPickerToIP4: () => this.family.value = 'ip4',
setFamilyPickerToIP6: () => this.family.value = 'ip6',
setProtocolPickerToICMP4: () => this.protocol.value = 'icmp4',
setProtocolPickerToICMP6: () => this.protocol.value = 'icmp6'
};
this.machine = interpret(createMachine().withConfig({ actions });
this.machine
.onTransition(state => this.state = state)
.start();
Another property we need is that the None address family option needs to dynamically disable in some cases. Since the machine uses its context.canSelectNone
we can use that in another getter to drive the template.
get isNoneDisabled() {
return !this.state.context.canSelectNone;
}
See the full component source.
Component (Template)
Usually a component will have one main element to represent it. In our case this element needs to wrap the whole form so that it can be the catalyst to state changes.
<div ...attributes data-state={{this.stateStrings}}>
Because our state machine actions reference DOM nodes we need to use ember-ref-modifier to assign references to the component.
{{#let (concat this.guid "-addressFamily") as |id|}}
<label for={{id}}>Address Family</label>
<select
name="addressFamily"
id={{id}}
{{on "change" (fn this.transition "PICK_FAMILY")}}
{{ref this "family"}}
>
<option value="none" disabled={{this.isNoneDisabled}}>None</option>
<option value="ip4">IP4</option>
<option value="ip6">IP6</option>
</select>
{{/let}}
We will call this.transition
with the PICK_FAMILY
event name and the DOMEvent when the user changes the select value. We store a reference to the select DOMNode in this.family
so that we can manipulate the select's value when the state machine triggers the appropriate actions. And we have added a toggle to the None option which reacts to the state to disable/enable it.
- Note
- One of the advantages to having a state machine is that you can control the response to events. In the case of the disabled None option even if the UI allowed changing it the state machine and the logic involved would ignore that as an invalid transition making the business logic hardened against hacking, fuzzing, or plain old user goofiness.
View the full template source.
Component (Styles)
With some creative CSS selectors we can make the template react to state changes without muddying the template with {{#if}}/{{else}}/{{/if}}
conditions everywhere. It also has the added advantage that the input elements never de-render and so their values stay even when the interaction changes the view state. Basically the user can enter data change the state to make the input hidden and return with the entered data intact.
[data-state~="addressFamily.none"][data-state~="protocol.any"] .networkingFields,
[data-state~="addressFamily.none"][data-state~="protocol.icmp4"] .networkingFields,
[data-state~="addressFamily.none"][data-state~="protocol.icmp6"] .networkingFields,
[data-state~="addressFamily.none"] [data-hide~="addressFamily.none"],
[data-state~="addressFamily.ip4"] [data-hide~="addressFamily.ip4"],
[data-state~="addressFamily.ip6"] [data-hide~="addressFamily.ip6"],
[data-state~="protocol.icmp4"] [data-hide~="protocol.icmp4"],
[data-state~="protocol.icmp6"] [data-hide~="protocol.icmp6"],
[data-state~="protocol.other"] [data-hide~="protocol.other"],
[data-state~="protocol.any"] [data-hide~="protocol.any"]
{
display: none;
}
The .networkFields
is unique in that it should only hide when both addressFamily and protocol states are at specific values.
I hope this offers a unique insight to how Statecharts can be used in UI. The inspiration for this use case was derived from the Parallel State page on statecharts.io.