Skip to content

gregwebs/StateTree

 
 

Repository files navigation

StateTree: simple javascript statechart implemenation.

What is a state chart?

A statechart is similar to a FSM (finite state machine), but extends the concept with hierarchy, concurrency, and communication.

Why statecharts?

An application responds to user interaction based on the previous state of the application. Most applications have a lot of implicit and ad-hoc state mutation that is difficult to understand and leads to bugs. In a simple app it is easy enough to manage by removing unecessary state and paying attention to detail. However, as applications become complex this gives every new feature the potential to break existing features. Statecharts were originally created to wrangle the complexity of jet fighter software, but they can scale down nicely to even simple applications. Rather than having implicit state mutation, Statecharts allow us to be very explicit about state and how it can be changed. This leads to fewer defects, lets us reason about how our application operates, and even explain the operation to non-programmers.

Every feature in this library was created to solve problems in an existing application.

Why not FSMs?

A statechart is an extension to the FSM concept to allow for nesting and concurrent states

Why not routing?

Routing is often used to describe application state. However, routing has difficulty handling concurrent states, handling nested state transitions, and maintaining history of different branches. Ember.js has taken a great approach of combining routing with a statechart. However, their code, including the underlying statemachine library does not work outside of the Ember framework.

StateTree

StateTree is an implementation of the statechart concept. The name comes from how a statechart can be modeled as a tree (hierarchy). StateTree can have multiple active branches (concurrency).

StateTree was developed for managing the state of a single-page client-side UI where the user is able to navigate around the entire UI. StateTree is different from most FSM and statecharts because the base library assumes that state transitions are allowed. This removes a lot of boilerplate when this assumpiton is correct. To restrict state, look at the signal add-on.

Perhaps the biggest feature of StateTree is safety.

Safety

Other statechart libraries ask you to give a large JSON structure to describe your state chart. JSON hierarchy looks nice, but these structures normally rely on strings that are a typo away from silent error. StateTree instead uses setter methods (aka builder pattern similar to other libraries such as d3) because they will always fail immediately at runtime if mistyped. With Typescript they fail at compile time.

StateTree is also designed to check for error conditions as soon as possible (usually during configuration) and immediately throw an exception. If you use TypeScript (or always call the library functions using the correct types), the goal is for there to be no way to use the library that leads to undefined behavior.

Typescript

StateTree leverages TypeScript to reduce bugs in the implementation and in user code. If you are a TypeScript user you can use the provided types to move some errors from runtime to compile time and also to have better autocompletion. If you are not a TypeScript user, just ignore the non-js files.

Alternatives

  • Stativus is a fully-featured statechart library.
  • If you are using Sproutcore or Ember, they have their own statechart library with a dependency on the frameworks.
  • statechart: does not offer concurrent substates.

StateTree is MIT licensed and does not require any frameworks (but does currently have a lodash/underscore dependency).

When StateTree was originally authored, the latter statechart library was in somewhat of an abandoned state, undocumented, and GPL licensed. It has since been switched to an MIT license and documentation has been added.

Stativus has buggy or at least undefined behavior where no error is thrown, and no log message is given, but the library doesn't work as expected. Tracking down these issues in the source code was very difficult, which motivated the creation of StateTree.

Dependencies

lodash/underscore (this dependency can be removed in the future)

Development Requirements

TypeScript (feel free to send a pure js patch and I can make sure it works in TypeScript)

Usage

Setup a state chart with enter and exit callbacks for different transitions. Changing the state is done with state.goTo() You do not indicate what the originating state is since that is already known. Instead, the state tree:

  • determines which concurrent substate the new state is in and moves within that concurrent substate
  • moves up the tree as necessary, exiting (invoke exit callback for) each state and setting history states.
  • moves down the tree and enters (invoke enter callback for) each state

Example: Application & UI state

// login service
function login(){ return {onApplicationStart:function(cb){setTimeout(cb)}} }

var authenticate = makeStateTree().root.subState("authenticate")
 .enter(function(){ login.onApplicationStart(function(){ loggedin.goTo()}})

// state references to export
var tab2 = null
var openPopup = null

var loggedin = authenticate.subState("loggedin", function(loggedin){
  loggedin.concurrentSubStates()

  // can't enter loggedin by calling main.goTo(),
  // must be loggedin.goTo() while in authenticate
  .onlyEnterThrough(authenticate)

  // You might be tempted to write something like:
  //
  //   .enter(function(){ main.goTo() })
  //
  // But generally avoid goTo in an enter/exit callback.
  // Just 1 is allowed during all transitions
  // for this case we can use defaultSubState to automatically enter "main"

  .subState("main", function(main){
    main.defaultSubState()
    .subState('tab1')
      // defaultSubState will choose betweent tab1 & tab2 when we call main.goTo()
      .defaultSubState()
      .enter(function(){ UI.activateTab('tab1')})
     
    tab2 = main.subState('tab2')
     .enter(function(){ UI.activateTab('tab2')})
  }) 
  
  loggedin.subState("popup", function(popup){
    openPopup = popup.subState("open")
      .enter(function() { popupService.popup() })
    
    popup.subState("closed")
      .enter(function(){ UI.unmask() })
  })
})

// start up the application
authenticate.goTo()
// user clicks a tab
tab2.goTo()
// popup a dialog
openPopup.goTo()

  • loggedin has concurrent substates (main and popup)
  • tab1 is the default substate of main
  • When the user enters the loggedin state, then enter callback will start the main state.
  • The main state will enter the tab1 state because it is the default sub state.
  • The popup can be opened and closed without effecting the main state.

Active states

StateTree keeps a hash of active states. Just call state.isActive() to determine if a state is active. There is also tree.currentStates() to find active leaves and tree.activeStates() to get an array of active states breadth-first.

History states

History states let us know the previous substate so we can easily restore previous application state.

// access the previous sub-state
state.history

// if there is a history state always go to it instead of the default state
tree.defaultToHistoryState()

Dynamic state lookup

tree.stateFromName('loggedIn')

Running code for every transition

Callbacks for all transitions can be registered with tree.enter and tree.exit

State local data

There is a field data on the State object reserved for state-local data. setData is a convenience configuration chaining method.

state.setData({}).enter((state) => state.data)

Global error handler

tree.handleError can be replaced with your own function. This function returns true to indicate that the state machine should continue moving through states as planned. false means stop the current transitions and instead keep the state machine in its current state.

Global enter/exit functions

tree.enter & tree.exit set a single global enter/exit handler function called after the state is entered/exited.

Events

For many use-cases, specifying events through which the statechart is allowed to transition can be very helpful. For that we have signals: see the next section.

For certain use-cases (like application state), specifying transitions through events isn't necessary. And you can also achieve some of the functionality through the interface you expose to your statechart. If you want to send data with a transition, you can create a wrapper function (and tie it to an event if you want).

 // wrapper function
 function goToStateA(arg1) {
   // do something with arg1
   stateA.goTo()
 } 

 // event hook: use your own event system
 myEventSystem.on('stateAEvent', goToStateA)

goTo also takes an optional data parameter that will be passed as the 2nd argument to the enter callback for the final destination state. Other parent states entered will not have their enter callback receive the data.

Signals

I am calling the event system signals because like js-signals it doesn't use strings.

Add the file statetree.signal.js to your dependencies after statetree.js

// given states: inactive, requesting, and queued
var requestCompleted = tree.signal('requestCompleted', (sig) => {
  sig.from(inactive, requesting).to(inactive)
     .from(queued).to(requesting)
       .with(() => queued.data.queuedReq)
})

Please note this is a new feature that I have only used for a single-level state machine, I will make sure it works well with hierarchies soon.

Intersection of multiple states

With the intersect function, we can specify enter callbacks that only occur when all of the states become active.

Lets say our application has multiple main tabs, and each tab has the same 2 different views. For each tab we could have 2 substates representing the 2 different views, but that may be cumbersome. Instead we may want just 2 states for our 2 different views, but we need to tie them into our main tabs. We can do this with an intersection.

var chart = {}
var viewToggle = root.subState('toggle2Views', function(viewToggle: State) {
  chart.viewOne = guideFeedToggle.subState('viewOne').enter(function() {
    activateTab('viewOne')
  })  
  chart.viewTwo = guideFeedToggle.subState('viewTwo').defaultState().enter(function() {
    activateTab('viewTwo')
  })  
})

sc.intersect(tab2, chart.viewOne).enter(function() {
   // code to run when both become active
})

Limiting/Enforcing transitions

This library provides one function: onlyEnterThrough. See usage in the main example.

If you want to limit access, just export a new object rather than all of the states. Instead of exporting states, you can export functions. These can handle data and limit usage to only valid state transitions.

 // statechart definition from the main example above of using statechart ...

 // don't export authenticate or loggedin, those need to be locked down
 return {
   goToTab2: function(){ tab2.goTo() } // private, but easy to use
 , openPopup: openPopup // public
 }

Flexible state lookup

Programming in strings is not safe, but sometimes gets the job done quickly. You can lookup a state from its name with tree.stateFromName(name:String):State.

Using with AngularJS

First make sure to require the statetree.js file.

You may want to make this library a module value

angular.module('StateTree', []).value('statetree', window.makeStateTree)

Then your own state service for your own application.

angular.module('myApp', ['StateTree'])
.factory('appState', ['statetree', function(statetree){
  // code from examples above goes here
}])

Now you can use this service in your controller

.controller('PopupController', ['$scope', 'appState', function(){
  $scope.closePopup = function() { appState.closed.goTo() }
}]

Integrated with routing

There are already a lot of routing libraries out there, so right now I want to take the approach of writing adapters.

There is an AngularJS adapter available. See the comments at the top of the file on how to set it up and also the API. This is all a work in progress, but it works well for me.

Here is some usage:

var mkRoute = routeGenerator(routeProvider, $location, $q, $rootScope)

showPopupState.subState("open-data-detail", function(openDataDetail) {
    var setDataId = (dataId) => dataViewer.setDataId(showId)
    var getDataId = () => [showViewer.getShowId()]
    mkRoute(openDataDetail, ['data', Number], getDataId, setDataId)
})

dataViewer is a service that retrieves data from a service and returns a promise. This links the state with a url /data/:dataId. If the user navigates to the url, the state will be entered after waiting for the promise from the setDataId function. If the state is entered programatically, it will use the getDataId function to change the url.

Routing now becomes a way to move around the statchart, so think carefully. onlyEnterThrough will stop transitions in their tracks, but we may instead want to wait until the transition is valid. For example, if the user must first log in, one way of dealing with that is to create a promise for the loggedin state.

var loginDefer = $q.defer()
loggedin.enter(function() { loginDefer.resolve() })

// change the definition of setDataId to require the loggedin state
var setDataId = (dataId) => $q.all([loginDefer.promise, dataViewer.setDataId(dataId)])

// or instead require every single route to wait for the login promise
var mkRoute = routeGenerator(routeProvider, $location, $q, $rootScope, loginDefer.promise)

Priorities

Concurrent substates means the user can be in multiple states that have routes at the same time. Normally whichever state is entered last will win. However, you can set a priority so that the concurrent state with the highest priority will be the winner (default priority is 0):

mkRoute(openDataDetail, ['data', Number], getParameters, setParameter, {priority: 1})

Probably there should be a way to represent multiple concurrent states in the url, but this feature may still be useful even then to prioritize which state shows first in the url.

Development

Generating files

tsc --sourcemap --declaration statetree.js.ts --out statetree.js

About

javascript statechart implementation

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages

  • TypeScript 47.9%
  • JavaScript 46.1%
  • CoffeeScript 6.0%