A statechart is similar to a FSM (finite state machine), but extends the concept with hierarchy, concurrency, and communication.
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.
A statechart is an extension to the FSM concept to allow for nesting and concurrent states
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 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.
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.
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.
- 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.
lodash/underscore (this dependency can be removed in the future)
TypeScript (feel free to send a pure js patch and I can make sure it works in TypeScript)
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
// 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.
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 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()
tree.stateFromName('loggedIn')
Callbacks for all transitions can be registered with tree.enter
and tree.exit
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)
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.
tree.enter
& tree.exit
set a single global enter/exit handler function called after the state is entered/exited.
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.
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.
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
})
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
}
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
.
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() }
}]
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)
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.
Generating files
tsc --sourcemap --declaration statetree.js.ts --out statetree.js