A statechart is like a traditional state machine with states, events, and transitions, but in a statechart each state is actually a node in a hierarchy. State nodes can be either clustered or concurrent.
Clustered states allow you to abstract common features from a collection of states so that the common behavior can be implemented in a single place. When a clustered state is current, exactly one of its child states must be current.
Concurrent states solve the state explosion problem that is so common in traditional state machines. With a traditional state machine, whenever the system has sub-systems with independent behavior, you need to create states that represent each possible combination of the sub-system states. This can easily get out of hand with even moderately complex systems, but statecharts solve this by allowing child states that operate independently. When a concurrent state is current, all of its child states are also current. This means that the current state of a statechart is not a single state, but a vector of states whose length is not fixed.
The Statechart
class allows you to build stateless statecharts. This may
seem counterintuitive at first, but it has many practical benefits. A
Statechart
instance is an object with a send
method that accepts a current
state and an event and returns a new state. The send
method does not mutate
the Statechart
instance, so you are responsible for maintaining the current
state elsewhere.
import Statechart from '@corey.burrows/statechart';
type Evt = {type: 'toggle'};
const toggle = new Statechart<{}, Evt>({}, s => {
s.state('on', s => {
s.on('toggle', '../off');
});
s.state('off', s => {
s.on('toggle', '../on');
});
});
let state = toggle.initialState;
console.log(state.paths); // ['/on']
state = toggle.send(state, {type: 'toggle'});
console.log(state.paths); // ['/off']
state = toggle.send(state, {type: 'toggle'});
console.log(state.paths); // ['/on']
This makes testing very easy because you are just testing a pure function that takes some inputs (current state and an event) and produces a result (the next state). We'll see later how we can make use of this to actually perform side effects in our statechart driven applications.
A statechart is just a collection of states and events. States can specify transitions to other states based on a given event. A statechart always has a current set of states. When an event is sent to the statechart, each current state gets an opportunity to handle it and optionally trigger a transition.
Simply tracking the current state of a system is usually not enough and you'll
want to maintain some additional data (such as model objects loaded from an API)
as your statechart moves through different states. This is called the state
context and is an arbitrary object that can be updated by state enter, exit, and
event handlers. You must specify the type of this object as a generic type
parameter when instantiating Statechart
.
The context is updated by returning an object from the enter/exit/event handler
with a context
key pointing to the newly updated context. You must be
careful to not perform mutable updates to the context object since that would
create a side effect.:
import Statechart from '@corey.burrows/statechart';
interface Ctx {
widgetId?: number;
}
type Evt = {type: 'SELECT_WIDGET'; id: number};
const statechart = new Statechart<Ctx, Evt>({}, s => {
s.state('home', s => {
s.on('SELECT_WIDGET', '../widgets');
});
s.state('widgets', s => {
s.enter((ctx, evt) => {
if (evt.type === 'SELECT_WIDGET') {
return {context: {...ctx, widgetId: evt.id}};
}
});
});
});
let state = statechart.initialState;
console.log(state.paths, state.context); // [ '/home' ] {}
state = statechart.send(state, {type: 'SELECT_WIDGET', id: 123});
console.log(state.paths, state.context); // [ '/widgets' ] { widgetId: 123 }
If you need to update the context and trigger a transition from an event
handler, you can do so by returning an object with both context
and goto
keys:
s.on('FOO', (ctx, evt) => {
return {context: {...ctx, foo: true}, goto: '/some/path'};
});
Clustered states allow you to group together states that share common behavior. For example, if you have several states that all need to trigger a transition to the same destination state on the same event, then you could group those states together under a clustered parent state and define the transition on the parent.
import Statechart from '@corey.burrows/statechart';
interface Ctx {}
type Evt = {type: 'one'} | {type: 'two'} | {type: 'three'} | {type: 'four'};
const statechart = new Statechart<Ctx, Evt>({}, s => {
s.state('A', s => {
s.on('three', '../B');
s.state('C', s => {
s.on('two', '../../B');
});
s.state('D', s => {
s.on('one', '../C');
});
});
s.state('B', s => {
s.on('four', '../A/D');
});
});
let state = statechart.initialState;
console.log(state.paths); // ['/A/C']
state = statechart.send(state, {type: 'three'});
console.log(state.paths); // ['/B']
We can see here that states C
and D
are refinements of state A
. The event
three
will trigger a transtion to state B
when either C
or D
are
current, but the event two
will only trigger a transition when state C
is
current.
The default state of a clustered state is the first defined child state. That is
why the initial state is /A/C
: A
is the first child state of the root state
and C
is the first child state of A
.
When a clustered state is entered during a transition it determines the child state to enter as follows:
- The child state specified by the given destination state.
- If the state has a condition function defined, the child state returned by calling it with the current context and event.
- If the state is marked as a history state and has been previously entered, the most recently exited child state.
- The default (first) child state.
As described above, condition functions can be defined on states to control which child state gets entered based on either the current context or the event that triggered the transition or a combination of both:
import Statechart from '@corey.burrows/statechart';
interface Ctx {}
type Evt = {type: 'x'; value: number};
const statechart = new Statechart<Ctx, Evt>({}, s => {
s.state('A', s => {
s.on('x', '../B');
});
s.state('B', s => {
s.C((ctx, evt) => (evt.type === 'x' && evt.value % 2 === 0 ? 'C' : 'D'));
s.state('C');
s.state('D');
});
});
let s1 = statechart.send(statechart.initialState, {type: 'x', value: 1});
console.log(s1.paths); // ['/B/D']
let s2 = statechart.send(statechart.initialState, {type: 'x', value: 2});
console.log(s2.paths); // ['/B/C']
The C
method of the state accepts a ConditionFn
which must return the name
of a child state. It gets passed the current context and event object that
triggered the transition.
Clustered states can also be marked as history states using the H
method.
History states remember their most recently exited child state and will enter
that state when re-entered.
import Statechart from '@corey.burrows/statechart';
interface Ctx {}
type Evt = {type: 'x'} | {type: 'y'} | {type: 'z'};
const statechart = new Statechart<Ctx, Evt>({}, s => {
s.state('A', s => {
s.on('x', '../B/D');
s.on('y', '../B');
});
s.state('B', s => {
s.H();
s.state('C');
s.state('D');
s.on('z', '../A');
});
});
let state = statechart.initialState;
console.log(state.paths); // ['/A']
state = statechart.send(state, {type: 'x'});
console.log(state.paths); // ['/B/D']
state = statechart.send(state, {type: 'z'});
console.log(state.paths); // ['/A']
state = statechart.send(state, {type: 'y'});
console.log(state.paths); // ['/B/D']
The y
event handler only specifies a transtion to the /B
state, but instead
of entering B
's default substate it enters substate D
instead since it is a
history state and D
was the most recently exited substate.
A state and all of its descendants can be recursivedly marked as history states
by passing the string '*'
to the H
method:
s.state('A', s => {
s.H('*');
s.state('B');
s.state('C', s => {
// State C is automatically a history state.
s.state('D');
s.state('E');
});
});
Concurrent states allow you to add independent behavior to your statechart without exploding the number of states and transitions you need to define. When a concurrent state is entered, all of its child states are entered. This creates a situation where the current state of the statechart is actually multiple states. When a concurrent state is current, events will be sent to all of the child states. Each child state can cause transitions within itself or outside of the concurrent state entirely, but they cannot cause transtions between the concurrent sibling states. Similar to entering a concurrent state, exiting a concurrent state causes all of its child states to be exited.
import Statechart from '@corey.burrows/statechart';
interface Ctx {}
type Evt =
| {type: 'toggleBold'}
| {type: 'toggleUnderline'}
| {type: 'leftClicked'}
| {type: 'rightClicked'}
| {type: 'centerClicked'}
| {type: 'justifyClicked'}
| {type: 'regularClicked'}
| {type: 'numberClicked'}
| {type: 'resetClicked'};
const statechart = new Statechart<Ctx, Evt>({}, s => {
s.concurrent();
s.state('bold', s => {
s.state('off', s => {
s.on('toggleBold', '../on');
});
s.state('on', s => {
s.on('toggleBold', '../off');
});
s.on('resetClicked', './off');
});
s.state('underline', s => {
s.state('off', s => {
s.on('toggleUnderline', '../on');
});
s.state('on', s => {
s.on('toggleUnderline', '../off');
});
s.on('resetClicked', './off');
});
s.state('align', s => {
s.state('left');
s.state('right');
s.state('center');
s.state('justify');
s.on('leftClicked', './left');
s.on('rightClicked', './right');
s.on('centerClicked', './center');
s.on('justifyClicked', './justify');
s.on('resetClicked', './left');
});
s.state('bullets', s => {
s.state('none', s => {
s.on('regularClicked', '../regular');
s.on('numberClicked', '../number');
});
s.state('regular', s => {
s.on('regularClicked', '../none');
s.on('numberClicked', '../number');
});
s.state('number', s => {
s.on('regularClicked', '../regular');
s.on('numberClicked', '../none');
});
s.on('resetClicked', './none');
});
s.on('resetClicked', [
'/bold/off',
'/underline/off',
'/align/left',
'/bullets/none',
]);
});
let state = statechart.initialState;
console.log(state.paths);
// [ '/bold/off', '/underline/off', '/align/left', '/bullets/none' ]
state = statechart.send(state, {type: 'toggleBold'});
console.log(state.paths);
// [ '/underline/off', '/align/left', '/bullets/none', '/bold/on' ]
state = statechart.send(state, {type: 'toggleUnderline'});
console.log(state.paths);
// [ '/align/left', '/bullets/none', '/bold/on', '/underline/on' ]
state = statechart.send(state, {type: 'rightClicked'});
console.log(state.paths);
// [ '/bullets/none', '/bold/on', '/underline/on', '/align/right' ]
state = statechart.send(state, {type: 'justifyClicked'});
console.log(state.paths);
// [ '/bullets/none', '/bold/on', '/underline/on', '/align/justify' ]
state = statechart.send(state, {type: 'regularClicked'});
console.log(state.paths);
// [ '/bold/on', '/underline/on', '/align/justify', '/bullets/regular' ]
state = statechart.send(state, {type: 'regularClicked'});
console.log(state.paths);
// [ '/bold/on', '/underline/on', '/align/justify', '/bullets/none' ]
state = statechart.send(state, {type: 'numberClicked'});
console.log(state.paths);
// [ '/bold/on', '/underline/on', '/align/justify', '/bullets/number' ]
state = statechart.send(state, {type: 'resetClicked'});
console.log(state.paths);
// [ '/bold/off', '/underline/off', '/align/left', '/bullets/none' ]
Transitions are how a statechart moves between states. They occur when an event
is sent to a statechart with a current state that handles the event and
indicates a transition should be made using the on
method.
A transition always involves one or more current states and one or more destination states. They work by first running the event handler on the current state and then exiting the current state up to, but not including, a pivot state. The pivot state is the nearest common ancestor between the current state and the destination state. Once the pivot state has been reached, the destination states are entered down to the final leaf states.
import Statechart from '@corey.burrows/statechart';
type Ctx = string[];
type Evt = {type: 'x'};
const statechart = new Statechart<Ctx, Evt>([], s => {
s.state('a', s => {
s.enter(ctx => ({context: [...ctx, 'enter:a']}));
s.exit(ctx => ({context: [...ctx, 'exit:a']}));
s.state('c', s => {
s.enter(ctx => ({context: [...ctx, 'enter:c']}));
s.exit(ctx => ({context: [...ctx, 'exit:c']}));
s.on('x', (ctx) => ({context: [...ctx, 'handle:x'], goto: '/b/f'}));
});
s.state('d', s => {
s.enter(ctx => ({context: [...ctx, 'enter:d']}));
s.exit(ctx => ({context: [...ctx, 'exit:d']}));
});
});
s.state('b', s => {
s.enter(ctx => ({context: [...ctx, 'enter:b']}));
s.exit(ctx => ({context: [...ctx, 'exit:b']}));
s.state('e', s => {
s.enter(ctx => ({context: [...ctx, 'enter:e']}));
s.exit(ctx => ({context: [...ctx, 'exit:e']}));
});
s.state('f', s => {
s.enter(ctx => ({context: [...ctx, 'enter:f']}));
s.exit(ctx => ({context: [...ctx, 'exit:f']}));
});
});
});
let state = statechart.initialState;
console.log(state.paths);
// [ '/a/c' ]
console.log(state.context);
// [ 'enter:a', 'enter:c' ]
state = statechart.send(state, {type: 'x'});
console.log(state.paths);
// [ '/b/f' ]
console.log(state.context);
// ['enter:a', 'enter:c', 'handle:x', 'exit:c', 'exit:a', 'enter:b', 'enter:f'];
In addition to triggering a transition with the goto
key, event handlers can
also update the context (as shown above) and queue actions.
For simple transitions, you can also specify just the destination state(s):
s.on('x', '../a');
s.on('y', ['../b/c', '../b/d']);
Although explicitly tracking a system's current state and extended state (via
context
) in a pure manner is quite useful, systems also often need to perform
(possibly asynchronous) side effects on the world such as fetching data or
rendering to the screen. Statechart
supports two types of side effects:
- Actions: one shot side effects that can be queued by state enter/exit handlers or event handlers
- Activities: recurrent side effects the run for the duration that the state that queued them are current
Note that the term queued is used to describe how states interact with side
effects. It is important to understand that a statechart does not actually
execute the effects, it simply adds them to the state
object returned by the
send
method where they must be executed by either the Machine
class or a
custom machine that you provide.
Actions are one shot side effects that can be queued by enter/exit handlers or
event handlers. An action is simply an object that implements an
exec(send: SendFn<E>): void
method. The exec method can perform some
asynchronous action and then use the given send
function to pass an event back
into the statechart.
import Statechart, {SendFn, ActionObj} from '@corey.burrows/statechart';
interface Widget {
id: number;
name: string;
}
interface Ctx {
loadingWidgets: boolean;
widgets: Widget[];
}
type Evt = {type: 'FETCH_WIDGETS_SUCCESS'; widgets: Widget[]};
class FetchWidgets {
exec(send: SendFn<Evt>): void {
setTimeout(() => {
send({
type: 'FETCH_WIDGETS_SUCCESS',
widgets: [
{id: 1, name: 'foo'},
{id: 2, name: 'bar'},
{id: 3, name: 'baz'},
],
});
}, 1);
}
}
const initCtx = {loadingWidgets: false, widgets: []};
const statechart = new Statechart<Ctx, Evt>(initCtx, s => {
s.state('widgets', s => {
s.state('loading', s => {
s.enter(ctx => ({
context: {...ctx, loadingWidgets: true},
actions: [new FetchWidgets()],
}));
s.exit(ctx => ({context: {...ctx, loadingWidgets: false}}));
s.on('FETCH_WIDGETS_SUCCESS', '../loaded');
});
s.state('loaded', s => {
s.enter((ctx, evt) =>
evt.type === 'FETCH_WIDGETS_SUCCESS'
? {context: {...ctx, widgets: evt.widgets}}
: {},
);
});
});
});
let state = statechart.initialState;
console.log(state.paths);
// [ '/widgets/loading' ]
console.log(state.context);
// { loadingWidgets: true, widgets: [] }
console.log(state.actions);
// [ FetchWidgets {} ]
(state.actions[0] as ActionObj<Evt>).exec((evt: Evt) => {
state = statechart.send(state, evt);
console.log(state.paths);
// [ '/widgets/loaded' ]
console.log(state.context);
// {
// loadingWidgets: false,
// widgets: [
// { id: 1, name: 'foo' },
// { id: 2, name: 'bar' },
// { id: 3, name: 'baz' }
// ]
// }
console.log(state.actions);
// []
});
Activities are similar to actions, but they are active for the duration of time that the state that queued them is current. This means that activities can only be queued by state enter handlers. Activities are useful for performing periodic side effects such as polling an API for new data. Activities are any object that implements the following methods:
start(send: SendFn<E>): void
stop(): void
The start
method is just like an action's exec
method. The stop
method
should stop whatever periodic timer the start
method starts.
import Statechart, {SendFn} from '@corey.burrows/statechart';
interface Ctx {
count: number;
}
type Evt = {type: 'TICK'} | {type: 'START'} | {type: 'STOP'};
class Ticker {
interval?: number;
start(send: SendFn<Evt>): void {
this.interval = (setInterval(() => {
send({type: 'TICK'});
}, 1000);
}
stop(): void {
clearInterval(this.interval);
}
}
const statechart = new Statechart<Ctx, Evt>({count: 0}, s => {
s.state('off', s => {
s.on('START', '../on');
});
s.state('on', s => {
s.enter(() => ({activities: [new Ticker()]}));
s.on('TICK', ctx => ({context: {...ctx, count: ctx.count + 1}}));
s.on('STOP', '../off');
});
});
let state = statechart.initialState;
state = statechart.send(state, {type: 'START'});
console.log(state.paths);
console.log(state.context);
state.activities.start[0].start((evt: Evt) => {
state = statechart.send(state, evt);
console.log('TICK:', state.context);
});
setTimeout(() => {
state = statechart.send(state, {type: 'STOP'});
console.log(state.paths);
console.log(state.context);
state.activities.stop[0].stop();
}, 3100);
// [ '/on' ]
// { count: 0 }
// TICK: { count: 1 }
// TICK: { count: 2 }
// TICK: { count: 3 }
// [ '/off' ]
// { count: 3 }
When a state enter handler queues an activty using the activities
key, the
statechart will add that activity to the returned state's activities.start
property. The outside machine code is responsible for actually calling the
object's start
method. Then whenever that state is eventually exited, the
activity will be moved to the return state's activities.stop
property.
As previously mentioned, a Statechart
instance is an immutable object that
provides a pure send
method that takes the current state and an event and
returns the next state. Any side effects are simply added to a queue, not
acutally executed.
To make use of a statechart, you'll need to provide some orchestration code to
maintain what the current state is and to execute side effects. The Statechart
library provides a Machine
class that does this, but you
may want to implement this yourself to depending on the needs of your
application.
Using the statechart from the activities example:
const machine = new Machine(statechart);
machine.start();
machine.send({type: 'START'});
setTimeout(() => {
machine.send({type: 'STOP'});
console.log(machine.paths); // [ '/off' ]
console.log(machine.context); // { count: 3 }
}, 3100);
Statecharts provide a simple and powerful pattern for managing complex state, particularly in user interface applications. Here are some general guidelines for their use in user interface applications:
- Create states to represent each possible view configuration
- Use clustered states to capture common behavior between states
- Use concurrent states to model indendent parts of the app
- Design the state context to be a minimal, flat, and fully normalized representation of the data needed by the application
- Use selector functions over the state context to compute derived data
- Use
enter
handlers to update the state context appropriate for the state's particular view configuration - Use
enter
handlers to queue any necessary side effects that the state needs (e.g. load data, start pollers) - Use
exit
handlers to clean up the context