State Machines for Frontend
State Management
@tejesh95
Tejesh P
We were building a Dashboard
We were building a Dashboard which has set of filters
We were building a Dashboard
Interaction Requirement
<div id ="filters">
<button type="button" class="active mb-4">China</button>
<button type="button" class="active mb-4">Nigeria</button>
<button type="button" class="active mb-4">India</button>
...
...
...
</div>
HTML
var all_regions_selected = true
Started Simple
var all_regions_selected = true
document.querySelector('#filters')
.addEventListener('click', function(event) {
if (event.target.tagName == 'BUTTON') {
}
})
Listen to button clicks on entire filters component
var all_regions_selected = true
document.querySelector('#filters')
.addEventListener('click', function(event) {
if (event.target.tagName == 'BUTTON') {
document.querySelectorAll('#filters button').forEach(function(node) {
if (node !== event.target) {
node.classList.remove('active')
}
})
}
})
Listen to click events on entire filters component
document.querySelector('#filters')
.addEventListener('click', function (event) {
if (event.target.tagName == 'BUTTON') {
if (document.querySelectorAll('#filters button.active').length === 6) {
document.querySelectorAll('#filters button').forEach(function (node) {
if (node !== event.target) {
node.classList.remove('active')
}
})
}
else if (document.querySelectorAll('#filters button.active').length === 1 &&
document.querySelector('#filters button.active') == event.target) {
document.querySelectorAll('#filters button')
.forEach(function (node) {
node.classList.add('active')
})
}
}
})
One Button selected to All buttons selected
document.querySelector('#filters')
.addEventListener('click', function (event) {
if (event.target.tagName == 'BUTTON') {
if (document.querySelectorAll('#filters button.active').length === 6) {
document.querySelectorAll('#filters button').forEach(function (node) {
if (node !== event.target) {
node.classList.remove('active')
}
})
}
else if (document.querySelectorAll('#filters button.active').length === 1 &&
document.querySelector('#filters button.active') == event.target) {
document.querySelectorAll('#filters button')
.forEach(function (node) {
node.classList.add('active')
})
} else {
event.target.classList.toggle('active')
}
}
})
what about multi-selection?
document.querySelector('#filters')
.addEventListener('click', function (event) {
if (event.target.tagName == 'BUTTON') {
if (document.querySelectorAll('#filters button.active').length === 6) {
document.querySelectorAll('#filters button').forEach(function (node) {
if (node !== event.target) {
node.classList.remove('active')
}
})
}
else if (document.querySelectorAll('#filters button.active').length === 1 &&
document.querySelector('#filters button.active') == event.target) {
document.querySelectorAll('#filters button')
.forEach(function (node) {
node.classList.add('active')
})
} else {
event.target.classList.toggle('active')
}
}
})
SPOT THE BUG?
Almost Correct!
var all_regions_selected = true
document.querySelector('#filters')
.addEventListener('click', function (event) {
console.log(event.target, event.target.tagName)
if (event.target.tagName == 'BUTTON') {
if (all_regions_selected === true) {
document.querySelectorAll('#filters button').forEach(function(node) {
if (node !== event.target) {
node.classList.remove('active')
}
})
all_regions_selected = false
}
else if (all_regions_selected === false) {
event.target.classList.toggle('active')
if (document.querySelectorAll('#filters button').length ===
document.querySelectorAll('#filters button.active').length) {
all_regions_selected = true
} else if (document.querySelectorAll('#filters button.active').length == 0) {
document.querySelectorAll('#filters button').forEach(function (node) {
node.classList.add('active')
})
}
}
}
})
Final Code
Thats too much of a logic!
prefer
Data structures are far more robust than code
DATA
OVER
CODE
{
state: {
action: function() {}
},
state: {
action: function() {}
},
state: {
action: function() {}
},
...
...
}
What if we had a better Data Structure?
Map/Dictionary/Object
{
'All Regions Selected': {
click: function (event) {
document.querySelectorAll('#filters button').forEach(function (node) {
if (node !== event.target) {
node.classList.remove('active')
}
})
}
},
'More than One Region Selected': {
click: function (event) {
event.target.classList.toggle('active')
}
},
}
This Mapping is actually a State Machine
Map/Dictionary/Object
What is a State Machine?
A machine is a data structure /container that has a set of states
- A machine can be only in one state
- A machine takes a sequence of events as input
- input + current state => next state
Current State | Event | Next State |
---|---|---|
One Region Selected | click | More than One Region Selected |
All Regions Selected | click | One Region Selected |
More than One Region Selected | click | More than One Region Selected |
More than One Region Selected | click | All Regions Selected |
One Region Selected | click | All Regions Selected |
Current State | Event | Next State |
---|---|---|
One Region Selected | click | More than One Region Selected |
All Regions Selected | click | One Region Selected |
More than One Region Selected | click | More than One Region Selected |
More than One Region Selected | click | All Regions Selected |
One Region Selected | click | All Regions Selected |
State Chart
State Chart
- Start state: A solid circle.
- End state: A solid circle with a ring around it.
- State: A rectangle with rounded corners, with the name of the action.
- Transition: Connector arrows with a label to indicate the trigger for that transition, if there is one.
- Guards or conditions: A diamond.
What I cannot create, I do not understand
- Richard Feynman
KickStarter
document.querySelector('#filters')
.addEventListener('click', function (event) {
if (event.target.tagName == 'BUTTON') {
input('click', event)
}
})
Engine
const input = function (event_name, args) {
const state = machine.currentState
// Action dispatcher
actions[machine.currentState][event_name](args)
// State Transition(er)
machine.currentState = machine.states[state][event_name](args)
// On State Event Triggers
stateEvents[machine.currentState]()
// Time Travel Logger
console.log(`${state} + ${event_name} --> ${machine.currentState}`)
}
State Machine
const machine = {
currentState: 'All Regions Selected',
states: {
'All Regions Selected': {
CLICK: function () {
return 'One Region Selected'
}
},
'One Region Selected': {
CLICK: function () {
if (....) {
return 'All Regions Selected'
}
return 'More than One Region Selected'
}
},
....
....
State Machine
const machine = {
currentState: 'All Regions Selected',
states: {
'All Regions Selected': {
CLICK: function () {
return 'One Region Selected'
}
},
'One Region Selected': {
CLICK: function () {
if (document.querySelectorAll('#filters button').length ===
document.querySelectorAll('#filters button.active').length) {
return 'All Regions Selected'
}
return 'More than One Region Selected'
}
},
....
....
On State Enter
const stateEvents = {
'All Regions Selected': function() {
$('#conditional button').prop("disabled", true)
},
'One Region Selected': function() {
$('#conditional button').prop("disabled", false)
},
'More than One Region Selected': function() {
$('#conditional button').prop("disabled", true)
}
}
XState
const filterMachine = XState.Machine(
{
id: 'click',
initial: 'All Regions Selected',
states: {
'All Regions Selected': {
on: {
CLICK: [
{
target: 'One Region Selected',
actions: ['deselect_other_than_clicked_button']
}
]
}
},
'More than One Region Selected': {
entry: ['toggle_button_state'],
on: {
CLICK: [
{
target: 'All Regions Selected',
cond: 'is_all_buttons_selected',
actions: ['toggle_button_state']
},
{
target: 'One Region Selected',
// clicked the second active button
cond: 'is_one_button_selected',
actions: ['toggle_button_state']
},
{
target: 'More than One Region Selected'
}
]
}
},
'One Region Selected': {
on: {
CLICK: [
{
target: 'All Regions Selected',
cond: 'is_theonlyactive_button_clicked',
actions: ['select_all']
}
]
}
}
}
},
{
actions: {
deselect_other_than_clicked_button: function (context, event, actionMeta) {
document.querySelectorAll('#filters button').forEach(function (node) {
if (node !== event.value.target) {
node.classList.remove('active')
}
})
},
select_all: function () {
document.querySelectorAll('#filters button').forEach(function (node) {
node.classList.add('active')
})
},
toggle_button_state: function (context, event, actionMeta) {
event.value.target.classList.toggle('active')
}
},
guards: {
is_all_buttons_selected: function (context, event) {
return (document.querySelectorAll('#filters button').length - 1===
document.querySelectorAll('#filters button.active').length) &&
event.value.target == document.querySelector('#filters button:not(.active)')
},
is_one_button_selected: function (ctx, event) {
return document.querySelectorAll('#filters button.active').length === 2 && event.value.target.classList.contains('active')
},
is_theonlyactive_button_clicked: function (context, event) {
return document.querySelector('#filters button.active') == event.value.target
}
}
}
)
var filterService = XState.interpret(filterMachine)
.onTransition((state) => console.log(state))
.start()
document.querySelector('#filters')
.addEventListener('click', function (event) {
if (event.target.tagName == 'BUTTON') {
filterService.send({ type: 'CLICK', value: event })
}
})
XState - Hierarical States
const filterMachine = XState.Machine(
{
id: 'click',
initial: 'All Regions Selected',
states: {
'All Regions Selected': {
on: {
CLICK: [
{
target: 'One Region Selected',
actions: ['deselect_other_than_clicked_button']
}
]
}
},
'More than One Region Selected': {
entry: ['toggle_button_state'],
on: {
CLICK: [
{
target: 'All Regions Selected',
cond: 'is_all_buttons_selected',
actions: ['toggle_button_state']
},
{
target: 'One Region Selected',
// clicked the second active button
cond: 'is_one_button_selected',
actions: ['toggle_button_state']
},
{
target: 'More than One Region Selected'
}
]
}
},
'One Region Selected': {
on: {
CLICK: [
{
target: 'All Regions Selected',
cond: 'is_theonlyactive_button_clicked',
actions: ['select_all']
}
]
}
}
}
},
{
actions: {
deselect_other_than_clicked_button: function (context, event, actionMeta) {
document.querySelectorAll('#filters button').forEach(function (node) {
if (node !== event.value.target) {
node.classList.remove('active')
}
})
},
select_all: function () {
document.querySelectorAll('#filters button').forEach(function (node) {
node.classList.add('active')
})
},
toggle_button_state: function (context, event, actionMeta) {
event.value.target.classList.toggle('active')
}
},
guards: {
is_all_buttons_selected: function (context, event) {
return (document.querySelectorAll('#filters button').length - 1===
document.querySelectorAll('#filters button.active').length) &&
event.value.target == document.querySelector('#filters button:not(.active)')
},
is_one_button_selected: function (ctx, event) {
return document.querySelectorAll('#filters button.active').length === 2 && event.value.target.classList.contains('active')
},
is_theonlyactive_button_clicked: function (context, event) {
return document.querySelector('#filters button.active') == event.value.target
}
}
}
)
var filterService = XState.interpret(filterMachine)
.onTransition((state) => console.log(state))
.start()
document.querySelector('#filters')
.addEventListener('click', function (event) {
if (event.target.tagName == 'BUTTON') {
filterService.send({ type: 'CLICK', value: event })
}
})
State Design Matters
Summary
-
simplifies the logic and makes it more predictable
-
debugging code is easy
-
when code goes to unpredicted state, we can trace the reason to previous states
-
less code to cover while testing
Cons
-
Needs upfront thinking - not needed for simple scenarios
-
Using a statechart increases the number of lines of code
Thank you!
Questions?
State Machines - XState (frontend)
By Tejesh P
State Machines - XState (frontend)
- 103