Skip to content

Latest commit

 

History

History
1015 lines (776 loc) · 45 KB

Guide.adoc

File metadata and controls

1015 lines (776 loc) · 45 KB

State Charts for Clojure(script)

A statechart is defined as a (nested) map. Some of this content’s behavior is configurable. The simplest setup is to use Clojure for the executable code and a simple flat data model that scopes all state charts to the session (running instance of state chart).

ℹ️
The SCXML standard is used for the semantics and processing, and MUCH of the overall structure. We keep the idea that each node in the chart has a unique ID, but take some license with executable content. Most of the "executable content" elements described in the SCXML standard have differences since we are not using XML. Sometimes we assume you can get by with an expression on the parent element, or just a single child script node.

To make it easier to write the maps there are functions for each element type in the com.fulcrologic.statecharts.elements namespace, and it is recommended that you use these because some of them validate their parameters and children.

Once you have a state chart definition you need to create a session, which is just a running instance of the chart.

Once you have a session, you can send events to it, and look at the content of the session (or store it, etc).

Here’s a traffic light example from the src/examples directory in this repository that leverages parallel and compound states to simulate traffic lights with pedestrian signals:

link:src/examples/traffic_light.cljc[role=include]

If you run the items in the comment block, you’ll see:

(:cross-ew/red :cross-ns/white :east-west/green :north-south/red)
(:cross-ew/red :cross-ns/flashing-white :east-west/green :north-south/red)
(:cross-ew/red :cross-ns/flashing-white :east-west/yellow :north-south/red)
(:cross-ew/white :cross-ns/red :east-west/red :north-south/green)
(:cross-ew/flashing-white :cross-ns/red :east-west/red :north-south/green)
(:cross-ew/flashing-white :cross-ns/red :east-west/red :north-south/yellow)
(:cross-ew/red :cross-ns/white :east-west/green :north-south/red)

History support includes both shallow and deep. Here’s a shallow example:

link:src/examples/history_sample.cljc[role=include]

See the SCXML spec for how to structure elements. The structure and naming are kept close to that spec for easy cross-referencing with its documentation.

The pure functional form for the charts is interesting and useful as a building-block, but if you want a fully-function SCXML-compatible system, you needs something that can run, deliver delayed events, send events from one statechart session to another, start/stop nested statecharts, etc.

In order for this to work you must have a number of things: an event queue, invocation processor(s), a data/execution model, a working memory storage facility, a statechart registry, and an event loop.

That’s a lot of different things to set up!

The com.fulcrologic.statecharts.simple namespace can set up all of these things for you, as long as you simple want to run statecharts all in the same JVM in RAM. That namespace also includes helper functions for starting a new session on a chart, and sending events to arbitrary sessions.

ℹ️
The design of this library is meant to accomplish much more complex distributed and long-lived systems, but you have to implement the various protocols for each of the above elements to build such a thing.

The basic steps for using simple are:

  1. Create a (simple/simple-env)

  2. Register your charts with the statechart registry in that env with (simple/register! env k chart)

  3. Run an event loop. The com.fulcrologic.statecharts.event-queue.core-async-event-loop/run-event-loop! uses core.async to run such a loop for you.

  4. Start one or more state charts, via the k you registered them under.

  5. (optionally) Send events (simple/send! env {:event :evt :target sessionid})

Below is an example that uses these steps, but also overrides one of the components in the env (the working memory store) so it can output messages as the statechart changes state:

link:src/examples/traffic_light_async.cljc[role=include]
ℹ️
The send element has a Send (capital S) alias so you can avoid shadowing the predefined Clojure function.

You can use the SCXML guide which has examples as a pretty good reference for documentation. The document structure in XML is nearly identical to the data structure of this library:

<scxml>
  <state id="A"/>
  ...
  </scxml>
(statechart {}
  (state {:id :A})
  ...)

See the docstrings on the statechart and various elements for details of differences.

Atomic State

A state that does not have substates.

Compound State

A state that has child states, where at most one child is active at any given time.

Parallel State

A state that has more than one compound substate, all of which is active at the same time, and thus have a child state that is active.

Configuration

When used in the context of a state chart, the configuration is the list of all active states. A state is active if it or ANY of its children are active. Thus, a parallel statechart with hierarchical states may have a rather large set of IDs in its current configuration.

Working Memory

A map of data that contains the current configuration of the state chart, and other information necessary to know its current full state (which states have had their data model initialized, possible data model tracking, etc.).

DataModel

An implementation of the data model protocol defines how the data model of your chart works. The simple implementation places the data in Working Memory, and treats lookups as paths into a single top-level map scoped to the session (running chart instance).

ExecutionModel

An implementation of this protocol is handed the expressions that are included on the state chart, and chooses how to run them. A default implementation requires that they be Clojure (fn [env data]), and simply calls them with the contextual data for their location.

External Event

An event that came from the external API, either by you calling process-event or some other actor code doing so (invocations, sends from external charts, etc.).

Internal Event

An event that came from the session that will process it. These come from the chart implementation itself (e.g. done evens) and from you using the raise element in an executable content position.

EventQueue

A FIFO queue for external event delivery. The event queue is responsible for holding onto pending External Events to "self", possibly other statechart sessions (instances of other charts), remote services (e.g. send an email), and for delayed delivery. The default implementation is a "manually polled" queue that does not support most of these features, and the delayed event delivery is manual (e.g. you have to poll it, or ask it when the next one should happen and start a timer/thread). Creating a system that runs the loop and does the timed delivery is typically how you would use these in a more serious/production environment.

Processor

An implementation of the statechart algorithm. This library comes with an implementation that follows (as closely as possible) the SCXML recommended standard from 2015. You may provide your own.

InvocationProcessor

A protocol representing things that can be invoked while in a state. Implementations are provided for statechart invocations, and (in CLJ) futures. It is a simple matter to expand the list of supported types.

WorkingMemoryStore

A protocol that represents a place to put the working memory of a statechart. This is used when you want an autonomous system that can "freeze" and "thaw" running sessions as events are received. Such a store could be in RAM (implementation provided) or in something more durable (Redis, SQL, Datomic, Filesystem).

StatechartRegistry

A protocol that allows a statechart to be saved/retrieved by a well-known name. An implementation is provided for tracking them in RAM, but you could also choose an execution model that allows your charts to be serializable, and then store the charts in something more durable.

Session

The combination of the content of the DataModel and Working Memory. I.e. all of the data you’d need in order to resume working from where you last left off. Sessions typically have a unique ID, which could be used to store sessions into durable storage when they are "idle", and are used for cross-session events.

Conditional Element

In statecharts there is a node type (represented as a diamond) that represents a state in which the chart never "rests", but instead immediately transitions to another node based on predicate expressions. In this library (and SCXML) this is modelled with a state that has more than one transition, NONE of which have and :event qualifier (meaning they are exited as soon as they are entered, assuming there is a valid transition).

States may be atomic, compound (hierarchical), or parallel. The first two are generated with the state element, and the latter with parallel.

Transitions are the consumers of events. Their "source" is their parent. Transitions can be eventless, and they can also have no target (indicating they do not change the active states, and are only for executing their content).

A state can have any number of transition elements. These are tested in order of appearance, and the first transition that matches the current circumstance is taken. Transitions are enabled when:

  • Their cond (if present) evaluates to true AND

  • Their event (if present) matches the current event’s name (See event name matching)

  • OR neither are present.

A transition that is marked external and targets its enclosing state will cause the exit/entry handlers to fire. An internal transition (that has no target at all) will not.

See the SCXML standard for other behaviors of transition.

See Shorthand Convenience for some alternative ways to represent transitions that are easier to read.

Condition states from classic state charts (shown as a diamond in UML state chart notation) can be modelled using eventless transitions.

An "eventless" transition without a :cond is always enabled.

Below is a conditional state that when entered will immediately transition to either state :X or :Y, depending on the result of the first transition’s condition expression:

(state {:id :Z}
  (transition {:cond positive-balance? :target :X})
  (transition {:target :Y}))
ℹ️
See Shorthand Convenience for a nicer-looking version of this.

In a fully-fleshed system your event queue would have a corresponding runtime story, where process-event was automatically called on the correct chart/session/invocation/service when an event is available. As such, and event queue might be distributed and durable (e.g. using Kafka or the like), or might simply be something that runs in-core (a core.async go-loop).

The library includes a fully-functional system for simple applications in the simple namespace.

The SCXML standard describes a name matching algorithm for event names that allows for wildcard pattern matching. Event names are dot-separated, and are prefix-matched. In this library keywords are used for event names, but other than that the interpretation is as specified in the standard. For example, the event name :a.b.c is matched by :a.b.c.*, :a.b.c, :a.b.*, :a.b, etc. That is to say that a transition with :event :a.b would be enabled by event :a.b.c.

The wildcard is always implied, but can be specified on a transition for clarity. Transitions are enabled and matched "in order", so you can model narrowed and catch-all behavior:

(state {:id A}
  (transition {:event :error.communication} ...)
  (transition {:event :error} ...))

where the first transition will be enabled only if the error is a communication one, and all errors will enable the second.

See the SCXML standard for the rules on transition preemption regarding conflicting transitions that match at the same time.

The SCXML standard (section 6.2.4) allows the format of the target to change based on the type, and implies that these are often URLs. The default implementation of the event queue in this library only supports targeting other statecharts (see Send Types below), and when using the default send type, the target is expected to simply be a session-id, where session-id is a unique identifier, such as a GUUID.

Other implementations of the EventQueue protocol may choose to define further refinements.

The SCXML standard (section 6.2.5) defines types as a URI as well. The internal implementations in this library do not enforce this, but this library does recognize the string "http://www.w3.org/TR/scxml/#SCXMLEventProcessor" as a desire to talk to another statechart session (and is the only/default predefined value).

There is a manually polled queue implementation in this library, with no automatic processing at all. If you want to support events that come from outside of the chart via the queue, then you have to create a loop that polls the queue and calls process-event!. If you do this, then event the delayed event delivery will work, as long as your code watches for the delayed events to appear on the queue.

link:src/examples/traffic_light.cljc[role=include]

A data model is a component of the system that holds data for a given state chart’s session. The SCXML specification allows the implementation quite a bit of latitude in the interpretation of the chart’s data model. You could define scopes that nest, one global map of data that is visible everywhere, or hook your data model to an external database.

See the docstrings in the protocols namespace.

There is a predefined FlatWorkingMemoryDataModel (in data-model.working-memory-data-model) that puts all data into a single scope (a map which itself can be a nested data structure). This is the recommended model for ease of use.

There is also a predefined WorkingMemoryDataModel in that scopes data to the state in which it is declared.

Both of these put the real data in the session working memory, allowing the data, for example, to be persisted with the state of a running session when pausing it for later revival.

Most people will probably just use the CLJCExecutionModel, which lets you write executable content in CLJC as lambdas:

;; Use `data` to compute when this transition is "enabled"
(transition {:event :a :cond (fn [env data] (your-predicate data))}
  ;; Run some arbitrary code on this transition
  (script {:expr (fn [env data] ...)}))

There is a macro version of the script element called script-fn that can be used as a shorthand for script elements:

;; Use `data` to compute when this transition is "enabled"
(transition {:event :a :cond (fn [env data] (your-predicate data))}
  ;; Run some arbitrary code on this transition
  (script-fn [env data] ...)))

The working memory of the state chart is plain EDN and contains no code. It is serializable by nippy, transit, etc. Therefore, you can easily save an active state chart by value into any data store. The value is intended to be as small as possible so that storage can be efficient.

Every active state chart is assigned a ID on creation (which you can override via initialize). This is intended as part of the story to allow you to coordinate external event sources with working with instances of chart that are archived in durable storage while idle.

An invocation is an "external" service/chart/process that can be started when your state chart enters a state, exchanges events while that state is active (you can forward events and receive them). The invocation can self-terminate while the state is still active, but it will also be cancelled if your chart leaves the enclosing state.

Invocations can forward events to the target invocation, and can receive events back. An incoming event will be pre-processed by finalize elements and can update the data model before the event is further propagated through the chart. See the SCXML standard for a full description of how invocations work.

The library has a built-in InvocationProcessor that knows how to start other statecharts via an invoke, and in CLJ there is also support for futures (which are cancelled if the state containing the invoke is exited).

The src/examples of the repository includes an example, shown below:

link:src/examples/invocations.cljc[role=include]

For example, suppose you want to install an invocation processor that can provide timer events on some interval. You could do something like:

link:src/examples/custom_invocation.cljc[role=include]

The transition element responds to the events received by the state chart, and the invocation processor for an invoke can send such events. So, the timer service here is sending :interval-timer/timeout events.

Making your statechart definition cleaner is a simple matter, since it is nothing more than a nested data structure.

One thing to note is that every element of the statechart can accept a nested sequence of children, and it will automatically flatten them. This means you can write helper functions that emit sequences of children, which in turn can use other helpers that might emit sequences.

Thus, macros and functions can be used to generate common patterns. The convenience and convenience-macros nses define some examples. These two namespaces are currently ALPHA and are not API stable, but it is a simple matter to copy their content if you like any of them and want to rely on them.

The macro versions expect you to be using the lambda execution model, and require that you use a symbol for the expression (that resolves to a function). They add some additional :diagram/??? attributes to the elements that are string versions of the expression, for use in diagram tools or possibly even export.

One common pattern is to schedule a delayed event on entry to a state, but cancel (if non-delivered) on exit. This helper exists in convenience, and looks like this:

(defn send-after
  [{:keys [id delay delayexpr] :as send-props}]
  (when-not id (throw (IllegalArgumentException. "send-after's props MUST include an :id.")))
  [(on-entry {}
     (Send send-props))
   (on-exit {}
     (cancel {:sendid id}))])

There are also some simple ones (as functions) that clean up readability for common cases:

(transition {:event :E :target :target-state}
  optional-executable-elements ...)

;; has a convenience version:
(on :E :target-state
  optional-executable-elements...)

;; or if there is just a script and no target (just an event handler that stays in the same state):
(handle :E expr)
;; means:
(transition {:event :E}
  (script {:expr expr}))

A good use-case for a macro comes up when you want to emit nodes that might be better suited for a diagram tool. The UML state chart system defines a choice node which is a node that makes a decision about where to transition to. In the SCXML standard they are coded as a state that includes nothing but event-less transitions with conditions:

  (state {:id node-id :diagram/prototype :choice}
    (transition {:cond pred :target target-state})
    (transition {:cond pred2 :target target-state2})
    (transition {:target else-target-state})

Which is not only a bit noisy, but it isn’t immediately obvious to the reader that this is a node that merely makes a choice. Additionally, with the lambda execution model the predicates are code, so there is no way for them to easily be emitted to a diagram.

So there is a macro version of this called choice in the convenience-macros namespace:

  (choice {:id node-id ...}
    pred  target-state
    pred2 target-state2
    :else else-target-state)

that is not only more concise, but adds {:diagram/condition "pred"} to the transitions properties (the stringified expression of the predicate).

There is also a function version of choice in convenience that does no add the diagram note, but looks identical to the reader.

See the docstrings in those namespaces for more functions/macros that can make your charts more concise.

The SCXML standard defines a number of elements it terms "Executable Content", meaning child nodes that do some action. For example, send is executable content of this on-entry node:

(state {}
  (on-entry {}
     (send ...)))

The standard allows for a compliant implementation to include extra executable content, but this library takes that one step further and allows you to define new executable content elements.

Each node in the data graph of the statecharts in this library (which are just nested maps) has a :node-type entry (in the case of send, its value is :send). The implementation of v20150901 allows you to extend the set of node types that "do things" as executable content. All you need to do is create a map that has a new (preferably namespaced) node type, and then create a defmethod that handles that type:

(ns my-node-types
  (:require
    [com.fulcrologic.statecharts.algorithms.v20150901-impl :as impl]))

(defn my-exec [opts]
  (merge opts {:node-type ::my-exec}))

(defmethod impl/execute-element-content! ::my-exec [env element] ...)

and you can use your (my-exec {…​}) anywhere executable content is allowed in the state chart. The helper function impl/execute! should be used to run any child nodes (if your node allows other executable children).

(ns my-node-types
  (:require
    [com.fulcrologic.statecharts.algorithms.v20150901-impl :as impl]))

(defn log-and-run [& children]
  (merge opts {:node-type ::log-and-run
               :children (vec children)}))

(defmethod impl/execute-element-content! ::log-and-run [env element]
  (log/debug "Running: " element)
  ;; The `execute!` method will run executable children of an element (which will call execute-element-content! on
  ;; each of them)
  (impl/execute! env element))

;; ...

(statechart {}
  (state {}
    (log-and-run
       (send {})
       (log {})
       (raise {}))))

The Fulcro integraion for statecharts has the following general enhancements over the standard statecharts:

  • Use the Fulcro app’s state database as the DataModel

  • Use core.async to automatically support an EventQueue

  • Support a "local data" area for the statechart session that won’t accidentally collide with other database concerns.

  • Allow for the use of actors (a component abstraction) and aliases (to database locations)

  • Supply an extensible set of operations that executable elements can leverage:

    • The ability to use Fulcro’s load to populate the state database.

    • The ability to invoke remote mutations.

    • The ability to leverage mutation helpers for optimistic updates.

Namespace aliases used in this document:

[com.fulcrologic.fulcro.application :as app]
[com.fulcrologic.fulcro.components :as comp :refer [defsc]]
[com.fulcrologic.statecharts.elements :as ele]
[com.fulcrologic.statecharts :as sc]
[com.fulcrologic.statecharts.chart :as chart]
[com.fulcrologic.statecharts.data-model.working-memory-data-model :as wmdm]
[com.fulcrologic.statecharts.data-model.operations :as ops]
[com.fulcrologic.statecharts.integration.fulcro :as scf]
[com.fulcrologic.statecharts.integration.fulcro.operations :as fop]
[com.fulcrologic.statecharts.protocols :as sp]

There are three basic steps: install the support, register machines, and start them:

;; 1. Install statecharts on the app. Should be done once at startup, but this is an idempotent operation.
(scf/install-fulcro-statecharts! app)

;; 2. Define your charts
(def chart (chart/statechart {}
         ...))

;; 3. Register your chart under a well-known name. NOTE: The statecharts have to be installed *before* calling register!
(scf/register-statechart! app ::chart chart)

...

;; 4. Start some statechart. The session-id can be a unique uuid, or a well known thing like a keyword. Be careful, though,
;; because starting a machine using an existing ID will overwrite the existing one.
(scf/start! app {:machine ::chart        ; registered chart name
                 :session-id :some-id})  ; globally unique session id

The Fulcro statecharts data model has a number of features to help you work with Fulcro applications.

Two special concepts were take from Fulcro’s own UI state machines: aliases and actors. These are implemented purely on the data model by simply adding entries to a statechart’s local data under the special keys:

  • :fulcro/aliases - A map from a keyword to a path. The path itself can be any legal DataModel path (see later) except for another alias.

  • :fulcro/actors - A map from a keyword to an scf/actor, which tracks the class and ident of a UI component. Can be used to find the UI component (for normalization/loads) and in data paths.

Notes:

  • Actors MUST be identified by keywords with an actor namespace: e.g. :actor/thing.

  • Actor keywords may only appear as the first element of a path.

  • Aliases are NOT used within paths. They define a path. Therefore arguments that would normally take a vector to describe a data path will usually accept an alias keyword instead.

You can use the initial data in sp/start! or a top-level data-model element to put data into the local storage of your statechart. This is particularly useful for specifying things like aliases on the chart itself, but defining actors at runtime:

(def c (chart/statechart {}
         (data-model {:expr {:fulcro/aliases {:a [:actor/thing :thing/field]}}})
         ...))
(scf/register-statechart! app ::c c)

...

(scf/start! app {:machine ::c
                 :session-id :some-id
                 :data {:fulcro/actors {:actor/this (scf/actor Thing [:thing/id 1])}}})

The above code defines a chart that will have :fulcro/aliases on the local data model because of the statechart definition, and will have a runtime value for the :fulcro/actors based on data that was passed during start. Anything initialized this way will go into the local data store (which is a path based on the session id in the app database).

Changing aliases and actors on the fly is therefore a simple matter of doing an op/assign operation on the data model.

There are four primary ways to address data in the data model, and the standard statechart operations such as op/assign already support these abstract paths:

A keyword (not in a vector)

IF this keyword exists in the :fulcro/aliases map, then the value of that alias (which can be a path that contains any of the other things in this list) is used to find the location; otherwise the keyword is relative in the root of the LOCAL data for the statechart.

A vector starting with :ROOT (or a keyword that doesn’t match the other cases)

A path in the local data of the statechart. Same as using a path without :ROOT. Included to be compatible with the standard location support.

A vector starting with :fulco/state or :fulcro/state-map

Indicates an absolute path in the Fulcro app database.

A vector starting with an actor name

If the first element of the vector matches an entry in the local statecharts :fulcro/actors then :fulcro/state and the ident of that actor are spliced together in place of that keyword and the resulting path is treated as above.

The executable content nodes (predicates and other expressions) in the statechart can be functions of two arguments: env (the processing environment) and data.

You can get the current statechart session ID using the processing environment, and you can also pull the various components of the statechart system from there (e.g. (::sc/event-queue env)). The data argument includes ALL of the statechart local data (including the special :fulcro/aliases and :fulcro/actors, which have special meaning but are really just normal local data). The data will also include the standard :_event (which is a map that has things like :target) and an extended key for :fulcro/state-map which has the current value of the Fulcro state database.

Non-predicate executable content (e.g. script) functions can return a vector of operations to run. The standard set (assign and delete) are supported, and use the extended path support described for the DataModel.

There are some additional operations for doing I/O:

(fop/invoke-remote txn options)

Run a single-mutation txn (e.g. [(f {:x 1})]) on a remote. The options allow you to specify events to trigger on the results. See scf/mutation-result. You can, of course use data :target and :returning to auto-merge graph data return values. The :target option can use an actor keyword as a convenience. :target Can be a normal Fulcro state-map path, a defined alias, or a path that can include actors (which will splice the actor’s ident into the target path).

(fop/load query-root component-or-actor options)

Issue a Fulcro load with an EQL query. options supposed the normal data fetch arguments, and additionally let’s you indicate what events to send when done/failed.

When using invoke-remote you will often not have a local Fulcro CLJS mutation. This means that you’d normally need to syntax-quote the transaction; however, remember that the Fulcro mutations namespace includes a declare-mutation helper that will make a "callable" function-like object that just returns itself as data.

(m/declare-mutation login app.authentication.session/login)

...

(def statechart
...
   (script {:expr [(fop/invoke-remote [(login {...})] {:ok-event :event/success :error-event :event/failed)]}))
(scf/local-data-path session-id)

Get the path of the local data for a given session. This is useful for adding a lookup ref to a UI component whose rendering depends on changes to this local state.

(scf/statechart-session-ident session-id)

Get the ident of the statechart session itself. This is useful for adding a lookup ref to a UI component whose rendering depends on changes to the statechart’s configuration.

(resolve-aliases data)

Used in executable content to return a map for all aliases. It looks up every alias from :fulcro/aliases and returns a map with them as keys, and their extracted data as values.

(resolve-actors data :actor/thing :actor/other …​)

Resolves the UI props of multiple named actors. The return value is a map from actor name to the UI props (tree).

(resolve-actors data :actor/thing)

Resolves the UI props of a single actor, and returns them.

(resolve-actor-class data actor-key)

Returns the Fulcro component that is currently acting as the UI counterpart of the named actor, if known.

(scf/send! app-ish session-id event optional-data)

Send an event to a running statechart.

(scf/current-configuration app-ish session-id)

Returns the current configuration (active states) of the given statechart instance. Useful in the UI when you need to render content based on state, but remember to add (scf/statechart-session-ident session-id) to any component query where that is necessary (to ensure render updates).

(scf/mutation-result data)

Extracts the raw return value of a remote mutation when the event being processed is the result of a mutation result that was originally triggered by a fops/invoke-remote.

(m/declare-mutation)

Makes a function-like object that can be used to generate remote mutation calls in transactions for invoke-remote.

The data parameter of runtime content (e.g. script nodes) contains:

  • The local data of the statechart (at the top level).

  • The special standard (from SCXML) :_event that is the event that is being processed, which in turn has:

    • :data that contains any data sent with the event.

    • :target The session ID of the statechart

  • A special :fulcro/state-map key that is the current value of the Fulcro application state.

The runtime env in executable elements includes:

  • Any data passed via the extra-env argument to install-fulcro-statecharts!

  • :fulcro/app - The Fucro app itself.

  • ::sc/statechart-registry - The statechart Registry instance

  • ::sc/data-model - The statechart DataModel instance

  • ::sc/event-queue - The statechart EventQueue instance

  • ::sc/working-memory-store - The statechart working memory store

  • ::sc/processor - The statechart processing algorithm

  • ::sc/invocation-processors - The supported invocation processors

  • ::sc/execution-model - The CLJC statechart ExecutionModel

There is support for using a statechart as a co-located element of a hooks-based component.

The basic idea is that the statechart will be started when the component uses it, and when the component leaves the screen the chart is sent an :event/unmounted. If the chart reaches a top-level final state, then it will be GC’d from state.

The session ID of the chart is auto-assigned a random UUID, but you can specify a known session ID to allow for a statechart to survive the component mount/unmount cycle.

Here is an example of using this support to create a simple traffic light that can regulate (red/yellow/green) or blink red:

(defsc TrafficLight [this {:ui/keys [color]}]
  {:query         [:ui/color]
   :initial-state {:ui/color "green"}
   :ident         (fn [] [:component/id ::TrafficLight])
   :statechart    (statechart {}
                    (state {:id :state/running}
                      (on :event/unmount :state/exit)
                      (transition {:event :event/toggle}
                        (script {:expr (fn [_ {:keys [blink-mode?]}]
                                         [(ops/assign :blink-mode? (not blink-mode?))])}))

                      (state {:id :state/green}
                        (on-entry {} (script {:expr (fn [_ _] [(fops/assoc-alias :color "green")])}))
                        (send-after {:delay 2000
                                     :id    :gty
                                     :event :timeout})
                        (transition {:event  :timeout
                                     :target :state/yellow}))
                      (state {:id :state/yellow}
                        (on-entry {} (script {:expr (fn [_ _] [(fops/assoc-alias :color "yellow")])}))
                        (send-after {:delay 500
                                     :id    :ytr
                                     :event :timeout})
                        (transition {:event  :timeout
                                     :target :state/red}))
                      (state {:id :state/red}
                        (on-entry {} (script {:expr (fn [_ _] [(fops/assoc-alias :color "red")])}))
                        (send-after {:delay 2000
                                     :id    :rtg
                                     :event :timeout})
                        (transition {:cond   (fn [_ {:keys [blink-mode?]}]
                                               (boolean blink-mode?))
                                     :event  :timeout
                                     :target :state/black})
                        (transition {:event  :timeout
                                     :target :state/green}))
                      (state {:id :state/black}
                        (on-entry {} (script {:expr (fn [_ _] [(fops/assoc-alias :color "black")])}))
                        (send-after {:delay 500
                                     :id    :otr
                                     :event :timeout})
                        (transition {:event  :timeout
                                     :target :state/red})))
                    (final {:id :state/exit}))
   :use-hooks?    true}
  (let [{:keys [send! local-data]} (sch/use-statechart this {:data {:fulcro/aliases {:color [:actor/component :ui/color]}}})]
    (dom/div {}
      (dom/div {:style {:backgroundColor color
                        :width           "20px"
                        :height          "20px"}})
      (dom/button {:onClick (fn [] (send! :event/toggle))}
        (if (:blink-mode? local-data) "Blink" "Regulate")))))

The main interface to this library is pure and functional, which makes the general job of testing easier in some cases, but the fact is that you often need to walk a state chart through several steps in order to get it into the starting configuration to test.

The fact that nodes can execute (possibly side-effecting) code also means that when testing it is usually desirable to eliminate these side effects through some kind of stubbing mechanism.

Fortunately the design of the library makes it trivial to plug in a mock execution model, mock event queue, and a test data model, which allows you to easily exercise a state chart in tests in ways that do not side effect in an uncontrolled fashion.

The com.fulcrologic.statecharts.testing namespace includes mocks for the necessary protocols, allows for a pluggable data model (defaults to flat working memory), and has pre-written helpers and predicates.

The mock execution model allows you to easily set up specific results for expressions and conditions that would normally be run.

Here is an example test (written with fulcro-spec):

link:src/test/com/fulcrologic/statecharts/testing_spec.cljc[role=include]

The mocking (set when creating the testing env) is a simple map (e.g. {is-tuesday? true}). The keys of the map must exactly match the expression in question (e.g. use defn to make them (as shown above), and then use those as the cond/expr so you can match on it). The values in the mocking map can be literal values OR functions. If they are functions then they will be passed the env, which will include the special key :ncalls that will have the count (inclusive) of the number of times that expression has run since the test env was created.

The sends and cancels will also be auto-recorded. See the docstrings in the testing namespace for more information.

Most of your tests will need the chart to be in some particular state as part of your setup. You could get there by triggering a sequence of events while having all of the mocks in perfect condition, but this creates a fragile test where changes to the structure of the chart break a bunch of tests. The testing helpers include (testing/goto-configuration! env data-ops config) that allows you to set up the data model and configuration.

The data-ops is a vector of Data Model operations (e.g. ops/assign) to run on the data model. and config is a valid configuration (set of active states) for the chart. Unfortunately, the configuration of a statechart is non-trivial (it must include all active states in a complex hierarchy) and will change when you refactor the chart. So, another helper testing/configuration-for-states allows you to get a list of all of the states that would be active given the deepest desired leaf state(s).

In a chart with no parallel states, there will only ever be one leaf, but when parallel states are active you must list a valid leaf from each region.

Thus, a common test setup will look like this:

(defn config [] {:statechart some-statechart})

(specification "Starting in Some State"
  (let [env (testing/new-testing-env (config) {})]

    ;; assume a top-level parallel node, with two sub-regions. An internal call to `configuration-for-states`
    ;; will populate all of the necessary ancestor states from these leaves.
    (testing/goto-configuration! env [] #{:state.region1/leaf :state.region2/leaf})
    (testing/run-events! env :event/expired)

    ;; assertions
    ))

This library’s internal implementation follows (as closely as possible) the official State Chart XML Algorithm. In fact, much of the implementation uses internal volatiles in order to match the imperative style of that doc for easier comparison and avoidance of bugs.

The actual structure of the live CLJC data used to represent charts also closely mimics the structure described there, but with some differences for convenient use in CLJC.

Specifically, executable content is still treated as data, but the XML nodes that are described in the standard do not all exist in this library, because a conformant XML reader (which would need to be aware of the target execution model) can easily translate such nodes into the target data representation (even if that target representation is script strings).

Some of the data model elements are also abbreviated in a similar manner. See the docstrings for details.

Thus, if you are trying to read SCXML documents you will need to write (or find) an XML reader that can do this interpretation.

For example, an XML reader that targets sci (the Clojure interpreter) might convert the XML (where a and do-something are implied values in the data and excution model):

<if cond="(= 1 a)">
  (let [b (inc a)]
    (do-something b))
</if>

into (scope and args still determined by the execution model selected):

;; String-based interpretation
(script {:expr
  "(if (= 1 a)
     (let [b (inc a)]
       (do-something b)))"})

;; OR eval-based
(script {:expr
  '(if (= 1 a)
     (let [b (inc a)]
       (do-something b)))})

;; OR functional
(script {:expr (fn [env {:keys [a]}]
                  (if (= 1 a)
                    (let [b (inc a)]
                      (do-something b))))})

If you’re using XML tools to generate your charts, though, it’s probably easiest to use script tags to begin with.

The primary alternative to this library is clj-statecharts, which is a fine library modelled after xstate.

This library exists for the following reasons:

  • At the time this library was created, clj-statecharts was missing features. In particular history nodes, which we needed. I looked at clj-statecharts in order to try to add history, but some of the internal decisions made it more difficult to add (with correct semantics) and the Eclipse license made it less appealing for internal customization as a base in commercial software (see https://www.juxt.pro/blog/prefer-mit).

  • To create an SCXML-like implementation that uses the algorithm defined in the W3C Recommended document, and can (grow to) run (with minor transformations) SCXML docs that are targeted to Clojure with the semantics defined there (such as they are).

  • To define more refined abstract mechanisms such that the state charts can be associated to long-lived things (such as a monetary transaction that happens over time) and be customized to interface with things like durable queues for events (e.g. AWS SQS) and reliable timers.

  • MIT licensing instead of Eclipse.

Other related libraries and implementations:

  • XState : Javascript. Could be used from CLJS.

  • Apache SCXML : Stateful and imperative. Requires writing classes. Requires you use XML.

  • Fulcro UI State Machines : A finite state machine namespace (part of Fulcro) that is tightly coupled to Fulcro’s needs (full stack operation in the context of Fulcro UI and I/O).

This library was written using the reference implementation described in the SCXML standard, but without the requirement that the chart be written in XML.

Any deviation from the standard (as far as general operation of state transitions, order of execution of entry/exit, etc.) should be considered a bug. Note that it is possible for a bugfix in this library to change the behavior of your code (if you wrote it in a way that depends on the misbehavior); therefore, even though this library does not intend to make breaking changes, it is possible that a bugfix could affect your code’s operation.

If future versions of the standard are released that cause incompatible changes, then this library will add a new namespace for that new standard (not break versioning).