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)

  • 69