Dynamic orthogonal regions in state machines
Sometimes just one sub-machine is not enough.
That's why orthogonal regions are so important when it comes to composing state machines. They let us say: here are my 2 sub-machines and I want both of them to be active simultaneously.
For exampe, let's consider a screen with two buttons:
Each button is a graph:
The code associated with the nodes of this graph simply displays a button with the proper text and makes it follow the toggled
arrow:
const makeButton = ({text}) => ({dispatch}) => ({handler: makeHandler({
TOGGLED: () => ({arrow: 'toggled'}),
RENDER: ({toNode}) => h('button', {
// Please note that the `toNode` function makes
// this action target this specific subtree.
// Otherwise, clicking a button
// could toggle more than just one button.
on: {click: () => dispatch(toNode({type: 'TOGGLED'}))}
}, text)
})});
const Button_On = makeButton({text: 'On'});
const Button_Off = makeButton({text: 'Off'});
const Button = {handler: defaultHandler};
The buttons then become orthogonal regions:
It means both nodes are active and equaly important.
The code associated with this dynamic composite may consume the results produced by its children. Here we are placing what they rendered in a div
:
const main = {
handler: makeHandler({
RENDER: alterResult(res =>
h('div', [
res.Lights,
res.Temperature,
])
),
}),
};
// Usually you want this part to be automatically generated
// with rosmaro-tools.
// Here it's been left to highlight the issue
// with a static list of orthogonal regions.
const bindings = (opts) => ({
'main': main,
'main:Lights': Button,
'main:Lights:On': Button_On(opts),
'main:Lights:Off': Button_Off(opts),
'main:Temperature': Button,
'main:Temperature:On': Button_On(opts),
'main:Temperature:Off': Button_Off(opts),
});
This technique is very helpful in avoiding state explosion.
But it's limited. It's static. We need to declare in the graph that there are exactly two orthogonal regions: Lights
and Temperature
. This cannot be changed while the machine is running. Imagine a multiplayer game suitable only for just 2 people, or even worse, a ToDo app that always shows exactly 2 items. It wouldn't be helpful at all!
To overcome this limitation when using a state machine library that's not flexible enough, developers often create many completely separated state machines and then rely on some other machanism to wire them up to the main machine. This may do the job, but it has its cons, for example:
- The need of manually creating and destroying extra instances of state machines.
- Switching between automata-based programming and some other programming paradigm, like object oriented programming or a framework, like React.
- No more tools to easily share and update the external state of the parent node.
- Working with a dynamic list of orthogonal regions becomes a lot harder that working with a static list of orthogonal regions.
Good for us, an elegant solution has already been described in the famous paper on statecharts by Prof. David Harel. It's all about dynamically repeating a single orthogonal region based on a value read from the external state:
Let's see how this feature is implemented in Rosmaro.
Instead of manually drawing a fixed number of orthogonal regions, we create a dynamic composite and select the node we want to repeat:
Then, we define a function of the context that returns a dynamic list of children:
const main = {
// This function returns the list of orthogonal regions.
nodes: ({context}) => context.switches,
handler: makeHandler({
RENDER: alterResult(res =>
// Instead of manually picking `Lights` and `Temperature`,
// we just render all the results.
h('div', values(res))
),
}),
// For the purpose of this presentation,
// we put two values in the context.
// In real life applications,
// the context may be populated as a result of user interaction.
lens: () => initialValueLens({switches: [
'Lights',
'Temperature',
]}),
};
const bindings = (opts) => ({
'main': main,
// 'main:child' is the node that is going to be repeated.
'main:child': Button,
'main:child:On': Button_On(opts),
'main:child:Off': Button_Off(opts),
});
Dynamic composites have many benefits when compared to statically defined orthogonal regions, for instance:
- The framework creates and scraps sub-machines for us in the correct moment.
- There's no need to switch between different mental models.
- The external state may be consumed and altered in the same way like when the list is static.
- We don't need to build any custom plumbing to communicate between machines. We still have just one statechart and all the rules that apply to following arrows and working with the context stay intact.
The difference is huge. If it were a template language, a static list of orthogonal regions could be compared to a static HTML file:
<ul>
<li>Carrot</li>
<li>Pepper</li>
</ul>
While a dynamic one looks more like this:
<ul>
{veggies.map(({name, id}) => (
<li key={id}>{name}</li>
)}
</ul>
Thank you for reading this brief introduction to dynamic composites! It was my pleasure to share this with you, because I do really find this technique amazing! When combined with other powerful tools, like lenses and a dispatch mechanism based on state machines, it makes things like building a ToDo app without a single boolean value or even a variable totally doable!