1. Getting Started

A state chart 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).

Note
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:

(ns traffic-light
  (:require
    [com.fulcrologic.statecharts :as sc]
    [com.fulcrologic.statecharts.chart :refer [statechart]]
    [com.fulcrologic.statecharts.elements :refer [parallel state transition]]
    [com.fulcrologic.statecharts.events :refer [new-event]]
    [com.fulcrologic.statecharts.protocols :as sp]
    [com.fulcrologic.statecharts.simple :as simple]
    [com.fulcrologic.statecharts.util :refer [extend-key]]))

(def nk
  "(nk :a \"b\") => :a/b
   (nk :a/b \"c\") => :a.b/c"
  extend-key)

(defn traffic-signal [id initial]
  (let [red     (nk id "red")
        yellow  (nk id "yellow")
        green   (nk id "green")
        initial (nk id (name initial))]
    (state {:id      id
            :initial initial}
      (state {:id red}
        (transition {:event :swap-flow :target green}))
      (state {:id yellow}
        (transition {:event :swap-flow :target red}))
      (state {:id green}
        (transition {:event  :warn-traffic
                     :target yellow})))))

(defn ped-signal [id initial]
  (let [red            (nk id "red")
        flashing-white (nk id "flashing-white")
        white          (nk id "white")
        initial        (nk id (name initial))]
    (state {:id      id
            :initial initial}
      (state {:id red}
        (transition {:event :swap-flow :target white}))
      (state {:id flashing-white}
        (transition {:event :swap-flow :target red}))
      (state {:id white}
        (transition {:event :warn-pedestrians :target flashing-white})))))

(def traffic-lights
  (statechart {}
    (parallel {}
      (traffic-signal :east-west :green)
      (traffic-signal :north-south :red)

      (ped-signal :cross-ew :red)
      (ped-signal :cross-ns :white))))

(defn show-states [wmem]
  (println
    (sort
      (filter #{:north-south/red
                :north-south/yellow
                :north-south/green
                :east-west/red
                :east-west/yellow
                :east-west/green
                :cross-ns/red
                :cross-ns/white
                :cross-ns/flashing-white
                :cross-ew/red
                :cross-ew/white
                :cross-ew/flashing-white}
        (::sc/configuration wmem)))))

(def env (simple/simple-env))
(simple/register! env ::lights traffic-lights)
(def processor (::sc/processor env))
(def s0 (sp/start! processor env ::lights {::sc/session-id 1}))
(show-states s0)
(def s1 (sp/process-event! processor env s0 (new-event :warn-pedestrians)))
(show-states s1)
(def s2 (sp/process-event! processor env s1 (new-event :warn-traffic)))
(show-states s2)
(def s3 (sp/process-event! processor env s2 (new-event :swap-flow)))
(show-states s3)
(def s4 (sp/process-event! processor env s3 (new-event :warn-pedestrians)))
(show-states s4)
(def s5 (sp/process-event! processor env s4 (new-event :warn-traffic)))
(show-states s5)
(def s6 (sp/process-event! processor env s5 (new-event :swap-flow)))
(show-states s6)

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:

(ns history-sample
  (:require
    [com.fulcrologic.statecharts :as sc]
    [com.fulcrologic.statecharts.chart :refer [statechart]]
    [com.fulcrologic.statecharts.elements :refer [history state transition]]
    [com.fulcrologic.statecharts.events :refer [new-event]]
    [com.fulcrologic.statecharts.protocols :as sp]
    [com.fulcrologic.statecharts.simple :as simple]))

(def sample
  (statechart {}
    (state {:id :TOP}
      (state {:id :A}
        (transition {:event :top :target :B}))
      (state {:id :B}
        ;; Could transition to :C, but that won't restore history.
        ;; Transitioning to (one of) the history nodes in C
        ;; directly restores history (you can have more than
        ;; one because you might want different "default" targets
        ;; for when there is no history).
        (transition {:event :top :target :Ch}))
      (state {:id :C}
        (transition {:event :top :target :A})
        (history {:id :Ch}
          (transition {:target :C1}))
        (state {:id :C1}
          (transition {:event :sub :target :C2}))
        (state {:id :C2}
          (transition {:event :sub :target :C1}))))))

(defn show-states [wmem] (println (sort (::sc/configuration wmem))))
(def env (simple/simple-env))
(simple/register! env `sample sample)
(def processor (::sc/processor env))

(def s0 (sp/start! processor env `sample {::sc/session-id 1}))
(show-states s0)
;; :TOP :A

(def s1 (sp/process-event! processor env s0 (new-event :top)))
(show-states s1)
;; :TOP :B

(def s2 (sp/process-event! processor env s1 (new-event :top)))
(show-states s2)
;; :TOP :C :C1

(def s3 (sp/process-event! processor env s2 (new-event :sub)))
(show-states s3)
;; :TOP :C :C2

(def s4 (sp/process-event! processor env s3 (new-event :top)))
(show-states s4)
;; :TOP :A

(def s5 (sp/process-event! processor env s4 (new-event :top)))
(show-states s5)
;; :TOP :B

(def s6 (sp/process-event! processor env s5 (new-event :top)))
(show-states s6)
;; :TOP :C :C2 (history remembered)

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.

2. Autonomous State Charts

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 state chart session to another, start/stop nested state charts, 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 state chart 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 simply want to run state charts 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.

Note
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 state chart 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 state chart changes state:

(ns traffic-light-async
  "A demo that uses the core.async queue to demonstrate a running machine that can just be
   sent events and mutates in place (and handles timers in CLJC) via core.async."
  (:require
    [com.fulcrologic.statecharts :as sc]
    [com.fulcrologic.statecharts.chart :refer [statechart]]
    [com.fulcrologic.statecharts.elements :refer [Send on-entry parallel state transition]]
    [com.fulcrologic.statecharts.event-queue.core-async-event-loop :as loop]
    [com.fulcrologic.statecharts.events :as evts]
    [com.fulcrologic.statecharts.protocols :as sp]
    [com.fulcrologic.statecharts.simple :as simple]
    [com.fulcrologic.statecharts.util :refer [extend-key]]))

(def nk
  "(nk :a \"b\") => :a/b
   (nk :a/b \"c\") => :a.b/c"
  extend-key)

(def flow-time "How long to wait before flashing ped warning" 2000)
(def flashing-white-time "How long do we warn peds?" 500)
(def yellow-time "How long do we warn cars" 200)

(defn timer []
  (state {:id :timer-control}
    (state {:id :timing-flow}
      (transition {:event  :warn-pedestrians
                   :target :timing-ped-warning})
      (on-entry {}
        (Send {:event :warn-pedestrians
               :delay flow-time})))
    (state {:id :timing-ped-warning}
      (transition {:event  :warn-traffic
                   :target :timing-yellow})
      (on-entry {}
        (Send {:event :warn-traffic
               :delay flashing-white-time})))
    (state {:id :timing-yellow}
      (transition {:event  :swap-flow
                   :target :timing-flow})
      (on-entry {}
        (Send {:event :swap-flow
               :delay yellow-time})))))

(defn traffic-signal [id initial]
  (let [red     (nk id "red")
        yellow  (nk id "yellow")
        green   (nk id "green")
        initial (nk id (name initial))]
    (state {:id      id
            :initial initial}
      (state {:id red}
        (transition {:event :swap-flow :target green}))
      (state {:id yellow}
        (transition {:event :swap-flow :target red}))
      (state {:id green}
        (transition {:event  :warn-traffic
                     :target yellow})))))

(defn ped-signal [id initial]
  (let [red            (nk id "red")
        flashing-white (nk id "flashing-white")
        white          (nk id "white")
        initial        (nk id (name initial))]
    (state {:id      id
            :initial initial}
      (state {:id red}
        (transition {:event :swap-flow :target white}))
      (state {:id flashing-white}
        (transition {:event :swap-flow :target red}))
      (state {:id white}
        (transition {:event :warn-pedestrians :target flashing-white})))))

(def traffic-lights
  (statechart {}
    (parallel {}
      (timer)

      (traffic-signal :east-west :green)
      (traffic-signal :north-south :red)

      (ped-signal :cross-ew :red)
      (ped-signal :cross-ns :white))))

(defn show-states [wmem]
  (println (sort (filter #{:north-south/red
                           :north-south/yellow
                           :north-south/green
                           :east-west/red
                           :east-west/yellow
                           :east-west/green
                           :cross-ns/red
                           :cross-ns/white
                           :cross-ns/flashing-white
                           :cross-ew/red
                           :cross-ew/white
                           :cross-ew/flashing-white} (::sc/configuration wmem)))))


(comment
  ;; Setup steps
  (do
    (def session-id 1)
    ;; Override the working memory store so we can watch our working memory change
    (def wmem (let [a (atom {})] (add-watch a :printer (fn [_ _ _ n] (show-states n))) a))
    ;; Create an env that has all the components needed, but override the working memory store
    (def env (simple/simple-env
               {::sc/working-memory-store
                (reify sp/WorkingMemoryStore
                  (get-working-memory [_ _ _] @wmem)
                  (save-working-memory! [_ _ _ m] (reset! wmem m)))}))

    ;; Register the chart under a well-known name
    (simple/register! env ::lights traffic-lights)

    ;; Run an event loop that polls the queue every 100ms
    (def running? (loop/run-event-loop! env 100)))

  ;; Start takes the well-known ID of a chart that should be started. The working memory is tracked internally.
  ;; You should see the tracking installed above emit the traffic signal pattern
  (simple/start! env ::lights session-id)

  ;; Tell the state machine to exit abruptly
  (simple/send! env {:target session-id
                     :event  evts/cancel-event})

  ;; Stop event loop
  (reset! running? false))
Note
The send element has a Send (capital S) alias so you can avoid shadowing the predefined Clojure function.

3. Async Execution Engine

The library includes an async-capable execution engine built on promesa that allows expressions to return promises the algorithm awaits. When all expressions are synchronous, the overhead is minimal. When an expression returns a promise, the algorithm parks until it resolves before continuing.

Both start! and process-event! may return promises instead of plain values. Callers must handle both cases. The easiest approach is to use promesa.core/let or promesa.core/then which handle both synchronous values and promises transparently.

The motivation for this features is CLJS statecharts, because the js ecosystem has a ton of promise-based asynchrony. In CLJ, you can write script tags that block:

(state {:id :state/foo}
  (on-entry {}
    (script-fn [env data] (db/run-query! "A")) ; blocks
    (script-fn [env data]  ; runs AFTER the first script
      (if (condition-needing-loaded-data-from-a)
        (db/run-query! "B"))))
  (state {:id :nested-state} ; IS NOT entered until all scripts finish
    ...)

but in JS-land, I/O is async, meaning you end up writing a bunch of states to "track progress". IF you use the async engine, then returning a promise from a script will cause the statechart to PARK until the promise completes, allowing you to write much simpler charts.

Note
This is an opt-in feature. The Fulcro integration can use it simply by passing a single parameter when you install the statecharts support.

3.1. Setting Up the Async Engine

Use the com.fulcrologic.statecharts.simple-async namespace for automatic configuration:

(require '[com.fulcrologic.statecharts.simple-async :as simple-async])

(let [env (simple-async/simple-env)]
  (simple-async/register! env :my-chart my-chart)
  (simple-async/start! env :my-chart session-id))

Or compose manually:

(require '[com.fulcrologic.statecharts.algorithms.v20150901-async :as alg]
         '[com.fulcrologic.statecharts.execution-model.lambda-async :as lambda-async])

{::sc/processor (alg/new-processor)
 ::sc/execution-model (lambda-async/new-execution-model dm event-queue)}

The async engine requires promesa on the classpath. Add it to your deps.edn:

{:deps {funcool/promesa {:mvn/version "11.0.678"}}}

Or use the :async alias if available in the project.

3.2. Using Async Expressions

Any expression (predicates, script nodes, etc.) can return a promise:

(state {:id :loading}
  (on-entry {}
    (script {:expr (fn [env data]
                     (p/let [result (http/get "/api/data")]
                       [(ops/assign :result result)]))})))

The algorithm will automatically detect promises and await them. Synchronous expressions continue to work without change.

4. Going Deeper

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.

4.1. Terminology

Atomic State

A state that does not have child states.

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 child state, all of which are 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 state chart 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 state chart 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 state chart 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 state chart 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 state chart. 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 state chart 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. 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 state charts 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).

4.2. States

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

4.3. Transitions

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.

4.4. "Condition" States

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}))
Note
See Shorthand Convenience for a nicer-looking version of this.

4.5. Flow Control Elements

The SCXML standard (Section 3.12) defines flow control elements for conditional branching and iteration within executable content. This library provides native Clojure function equivalents:

4.5.1. If / elseif / else

Use If (capitalized to avoid collision with clojure.core/if), elseif, and else for multi-way branching inside executable content such as on-entry, on-exit, or transition bodies.

The :cond on If and elseif is an expression compatible with your execution model (typically (fn [env data] …​) returning a truthy value). Children before the first elseif or else form the "then" branch:

(on-entry {}
  (assign {:location [:x] :expr 2})
  (If {:cond (fn [_ data] (= (:x data) 1))}
    (assign {:location [:result] :expr :first})
    (elseif {:cond (fn [_ data] (= (:x data) 2))}
      (assign {:location [:result] :expr :second}))
    (elseif {:cond (fn [_ data] (= (:x data) 3))}
      (assign {:location [:result] :expr :third}))
    (else {}
      (assign {:location [:result] :expr :default}))))

Each branch can contain any executable content (including nested If statements). This is particularly useful for leveraging nested raise, allowing you to conditionally queue events on the internal queue without having the write a script or use the external queue.

4.5.2. foreach

Use foreach to iterate over a collection, executing children for each item:

  • :array — An expression returning the collection to iterate over

  • :item — A location (keyword or vector) where the current item is bound

  • :index — (optional) A location where the current zero-based index is bound

(on-entry {}
  (assign {:location [:evens] :expr []})
  (foreach {:array (fn [_ _] [1 2 3 4 5 6])
            :item  [:n]}
    (If {:cond (fn [_ data] (even? (:n data)))}
      (assign {:location [:evens]
               :expr     (fn [_ data]
                           (conj (:evens data) (:n data)))}))))

An empty collection simply skips the body. foreach and If can be freely nested.

4.5.3. The In Predicate

The W3C SCXML standard defines an In() predicate (Section 5.9) for checking whether a state is in the current configuration. This library provides In as a function that returns a predicate:

(transition {:event :check :target :next :cond (In :some-state)})

(In :some-state) returns a (fn [env data] …​) that checks whether :some-state is in the active configuration. This is useful for guards on transitions in parallel state charts where you need to coordinate across regions.

4.6. Event Processing

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.

4.6.1. Event Name Matching

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.

4.6.2. Send Targets

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 state charts (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.

4.6.3. Send Types

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 state chart session (and is the only/default predefined value).

4.6.4. Manual Polling Queue

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.

(ns traffic-light
  (:require
    [com.fulcrologic.statecharts :as sc]
    [com.fulcrologic.statecharts.chart :refer [statechart]]
    [com.fulcrologic.statecharts.elements :refer [parallel state transition]]
    [com.fulcrologic.statecharts.events :refer [new-event]]
    [com.fulcrologic.statecharts.protocols :as sp]
    [com.fulcrologic.statecharts.simple :as simple]
    [com.fulcrologic.statecharts.util :refer [extend-key]]))

(def nk
  "(nk :a \"b\") => :a/b
   (nk :a/b \"c\") => :a.b/c"
  extend-key)

(defn traffic-signal [id initial]
  (let [red     (nk id "red")
        yellow  (nk id "yellow")
        green   (nk id "green")
        initial (nk id (name initial))]
    (state {:id      id
            :initial initial}
      (state {:id red}
        (transition {:event :swap-flow :target green}))
      (state {:id yellow}
        (transition {:event :swap-flow :target red}))
      (state {:id green}
        (transition {:event  :warn-traffic
                     :target yellow})))))

(defn ped-signal [id initial]
  (let [red            (nk id "red")
        flashing-white (nk id "flashing-white")
        white          (nk id "white")
        initial        (nk id (name initial))]
    (state {:id      id
            :initial initial}
      (state {:id red}
        (transition {:event :swap-flow :target white}))
      (state {:id flashing-white}
        (transition {:event :swap-flow :target red}))
      (state {:id white}
        (transition {:event :warn-pedestrians :target flashing-white})))))

(def traffic-lights
  (statechart {}
    (parallel {}
      (traffic-signal :east-west :green)
      (traffic-signal :north-south :red)

      (ped-signal :cross-ew :red)
      (ped-signal :cross-ns :white))))

(defn show-states [wmem]
  (println
    (sort
      (filter #{:north-south/red
                :north-south/yellow
                :north-south/green
                :east-west/red
                :east-west/yellow
                :east-west/green
                :cross-ns/red
                :cross-ns/white
                :cross-ns/flashing-white
                :cross-ew/red
                :cross-ew/white
                :cross-ew/flashing-white}
        (::sc/configuration wmem)))))

(def env (simple/simple-env))
(simple/register! env ::lights traffic-lights)
(def processor (::sc/processor env))
(def s0 (sp/start! processor env ::lights {::sc/session-id 1}))
(show-states s0)
(def s1 (sp/process-event! processor env s0 (new-event :warn-pedestrians)))
(show-states s1)
(def s2 (sp/process-event! processor env s1 (new-event :warn-traffic)))
(show-states s2)
(def s3 (sp/process-event! processor env s2 (new-event :swap-flow)))
(show-states s3)
(def s4 (sp/process-event! processor env s3 (new-event :warn-pedestrians)))
(show-states s4)
(def s5 (sp/process-event! processor env s4 (new-event :warn-traffic)))
(show-states s5)
(def s6 (sp/process-event! processor env s5 (new-event :swap-flow)))
(show-states s6)

4.7. Data Models

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.

4.8. Execution Models

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] ...)))

4.9. Working Memory and Identity

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.

4.10. Accessing Running Charts from Outside the Chart

While you can work with the core algorithms as pure function, you will normally set up a standard system environment (where you provide an event queue and working memory store). When working with such a system you may have many charts running (stored in the working memory store) and can send them events via the event queue.

The com.fulcrologic.statecharts.runtime namespace includes functions which can be used to work with such a system from outside the chart itself.

current-configuration

Get the set of active states for by session id.

processing-env

Get a processing env (using a system env and session id)

session-data

Get internal state chart data from a particular state chart session.

send!

Send an event. The simple system has this function as well, and it is a standard part of the protocols, but this wrapper makes it a little more convenient to use when you already have a system env map.

Warning
Beware the term "env". In this library a system environment is a map that contains a key for the various components of the system (e.g. the event queue, processor, execution model, etc.). A processing env is an system environment which also includes the current details of a particular session and is the env you see in the lambdas in your chart. The latter can be used anywhere the former is needed, but not vice-versa.

4.11. Invocations

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.

4.11.1. Passing Invocation Data

The invoke element has both a :namelist and :params argument. The former allows you to list data locations of the parent that should be copied to the child. :params is dynamic, and allows you to specify expressions that calculate values to inject into the child data model. These options can be used together, with params taking precedence.

(invoke {:namelist {[:area :x] [:target :y]}
         :params (fn [env data] {[:other :y] 42
         ...})

4.11.2. Invoking other State Charts or Futures

The library has a built-in InvocationProcessor that knows how to start other state charts 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:

(ns invocations
  (:require
    [com.fulcrologic.statecharts :as sc]
    [com.fulcrologic.statecharts.algorithms.v20150901-validation :as v]
    [com.fulcrologic.statecharts.chart :refer [statechart]]
    [com.fulcrologic.statecharts.elements :refer [Send data-model final invoke log on-entry on-exit state transition]]
    [com.fulcrologic.statecharts.environment :as e]
    [com.fulcrologic.statecharts.event-queue.core-async-event-loop :as loop]
    [com.fulcrologic.statecharts.simple :as simple]
    [taoensso.timbre :as log]))

(def child-chart
  (statechart {}
    (data-model {:x 1})
    (state {}                                               ; top-level state so we can log an exit from the overall machine
      (on-exit {}
        (log {:level :info
              :label "CHILD CHART EXIT"}))

      (transition {:event  :event/exit
                   :target :state/final})

      (state {:id :X}
        (on-entry {}
          (log {:level :info
                :label "Child X" :expr (fn [_ {:keys [x]}] x)}))
        ;; NOTE: The future invocation processor is only coded for CLJ, so this invocation is ignored in CLJS
        (invoke {:id     :child/future
                 :type   :future
                 :params {:time (fn [_ {:keys [x]}] x)}
                 :src    (fn [{:keys [time]}]
                           (log/info "Invocation running for" time)
                           #?(:clj (try
                                     (Thread/sleep (long (* 1000 time)))
                                     (catch InterruptedException e
                                       (log/info "Future cancelled!")
                                       (throw e))))
                           (log/info "Future function completed")
                           {:future-result :DONE})})
        (transition {:event :done.invoke.*}
          (log {:level :info
                :label "Future indicated that it completed"
                :expr  (fn [e d]
                         (log/spy :debug d))}))
        (transition {:event :child/swap :target :Y}))
      (state {:id :Y}
        (on-entry {}
          (log {:level :info
                :label "Child Y"}))
        (on-exit {}
          (Send {:event      :child.left-y
                 :type       ::sc/chart
                 :targetexpr (fn [env data]
                               (e/parent-session-id env))}))
        (transition {:event :child/swap :target :X})))
    (final {:id :state/final})))

(comment
  (v/problems child-chart))

(def main-chart
  (statechart {}
    (state {:id :A}
      (on-entry {}
        (log {:level :info
              :label "Main A"}))
      (transition {:event :swap :target :B}))
    (state {:id :B}
      (on-entry {}
        (log {:level :info
              :label "Main B"}))
      (transition {:event :swap :target :A})
      (transition {:event :child.*}
        (log {:level :info
              :label "Child event" :expr (fn [_ data] data)}))
      (invoke {:id          :main/child
               :autoforward true                            ; be careful, sends all events through, but it is possible to cause infinite loops if child sends events in response
               :type        :statechart
               :src         `child-chart
               :params      {:x 10}
               :finalize    (fn [env data]
                              (log/info "Event from child to parent: " data))}))))

(comment
  (do
    (log/set-level! :info)                                  ; so we can see log elements
    (def session-id 42)
    (def env (simple/simple-env))
    (simple/register! env `main-chart main-chart)
    (simple/register! env `child-chart child-chart)
    (def running? (loop/run-event-loop! env 100))
    (simple/start! env `main-chart session-id))

  ;; Sending this multiple times with swap main chart states. When in B, a child chart will start.
  (simple/send! env {:target session-id
                     :event  :swap})

  (::sc/working-memory-store env)
  ;; With autoforward on, we can ferry events though to the child
  (simple/send! env {:target session-id
                     :event  :child/swap})

  ;; When the child chart is running (top chart is in :B), You can send it events.
  ;; The session ID of a child session will be the ID of the node, of if you don't supply one an autogenerated
  ;; one will be stored at idlocation with the string value <parent-session-ID `.` <generated-id>
  (simple/send! env {:target :main/child
                     :event  :child/swap})

  (simple/send! env {:target :main/child
                     :event  :event/exit})

  ;; terminate the event loop
  (reset! running? false))

4.11.3. Custom Invocation Processors

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

(ns custom-invocation
  (:require
    [clojure.core.async :as async]
    [com.fulcrologic.statecharts :as sc]
    [com.fulcrologic.statecharts.chart :refer [statechart]]
    [com.fulcrologic.statecharts.data-model.operations :as ops]
    [com.fulcrologic.statecharts.elements :refer [invoke script-fn state transition]]
    [com.fulcrologic.statecharts.environment :as env]
    [com.fulcrologic.statecharts.event-queue.core-async-event-loop :as loop]
    [com.fulcrologic.statecharts.events :as evts]
    [com.fulcrologic.statecharts.protocols :as sp]
    [com.fulcrologic.statecharts.simple :as simple]
    [taoensso.timbre :as log]))

(deftype Timer [active-invocations]
  sp/InvocationProcessor
  (supports-invocation-type? [_this typ] (= typ :timer))
  (start-invocation! [_this {::sc/keys [event-queue]
                             :as       env} {:keys [invokeid params]}]
    (let [source-session-id (env/session-id env)
          {:keys [interval]
           :or   {interval 1000}} params
          time-id           (str source-session-id "." invokeid)
          notify!           (fn []
                              (log/info "sending notification")
                              (sp/send! event-queue env
                                {:target            source-session-id
                                 :send-id           time-id
                                 ;; IMPORTANT: If you don't include the invokeid, then it won't register in finalize
                                 :invoke-id         invokeid
                                 :source-session-id time-id
                                 :event             :interval-timer/timeout}))]
      (swap! active-invocations assoc time-id true)
      (async/go-loop []
        (async/<! (async/timeout interval))
        (if (get @active-invocations time-id)
          (do
            (notify!)
            (recur))
          (log/info "Timer loop exited")))
      true))
  (stop-invocation! [_ env {:keys [invokeid] :as data}]
    (log/spy :info data)
    (log/info "Invocation" invokeid "asked to stop")
    (let [source-session-id (env/session-id env)
          time-id           (str source-session-id "." invokeid)]
      (swap! active-invocations dissoc time-id)
      true))
  (forward-event! [_this _env _event] nil))

(defn new-timer-service
  "Create a new time service that can be invoked from a state chart."
  [] (->Timer (atom {})))

(def demo-chart
  (statechart {}
    (state {:id :A}
      (transition {:event :swap :target :B}))
    (state {:id :B}
      (transition {:event :swap :target :A})
      (transition {:event :interval-timer/timeout}
        (script-fn [_ data] (log/info "Main transition got data" (select-keys data [:B :timer-calls]))))

      (invoke {:idlocation [:B :invocation-id]
               :type       :timer
               :params     {:interval 500}
               :finalize   (fn [_env {:keys [_event timer-calls]}]
                             (log/info "Finalize got timer calls data: " [timer-calls _event])
                             ;; Finalize gets to update the model before the event is delivered...
                             [(ops/assign :timer-calls (inc (or timer-calls 0)))])}))))

(comment
  (do
    (def env (simple/simple-env {::sc/invocation-processors [(new-timer-service)]}))
    (simple/register! env `demo-chart demo-chart)
    (def queue (::sc/event-queue env))
    (def processor (::sc/processor env))
    (def session-id 42)
    (def wmem (atom {}))
    (def running? (loop/run-event-loop! env 100)))

  (simple/start! env `demo-chart session-id)

  ;; Send this one multiple times
  (simple/send! env {:target session-id
                     :event  :swap})

  ;; Send this one to exit the top level machine
  (simple/send! env {:target session-id
                     :event  evts/cancel-event})

  ;; stop the event loop
  (reset! running? false)
  )

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.

5. Shorthand Convenience

Making your state chart 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 state chart 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.

6. Custom Executable Content

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 since this library is in Clojure you have the power of macros and functions, which XML does not.

The internals allow you to return a collection of elements from a function, which will be "spliced" in place. This allows you to easily create macros and functions that can make your state charts much easier to write. Some examples in the library source code are the com.fulcrologic.statecharts.convenience-macros/choice macro, and functions like com.fulcrologic.statecharts.integration.fulcro.routing/rstate in the Fulcro UI Routing support.

7. Integration with Fulcro

The Fulcro integration for state charts has the following general enhancements over the standard state charts:

  • 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 state chart 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.integration.fulcro.async-operations :as afop]
[com.fulcrologic.statecharts.protocols :as sp]

7.1. Basic Usage

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

;; 1. Install state charts 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 state charts have to be installed *before* calling register!
(scf/register-statechart! app ::chart chart)

...

;; 4. Start some state chart. 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

7.2. The Data Model

The Fulcro state charts 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 state chart’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.

7.2.1. Initializing the Data Model

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 state chart. 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 state chart 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.

7.2.2. Data Locations

There are four primary ways to address data in the data model, and the standard state chart 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 state chart.

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

A path in the local data of the state chart. 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 state charts :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.

7.3. Executable Content

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

You can get the current state chart session ID using the processing environment, and you can also pull the various components of the state chart system from there (e.g. (::sc/event-queue env)). The data argument includes ALL the state chart 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.

7.3.1. Operations

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)]}))

7.3.2. Async Operations

When using the async execution engine (:async? true), you can issue Fulcro loads and remote mutations that resolve as promises within the algorithm. The chart transitions atomically — it never observably rests in a "loading" state.

The point is to make async operations "park" (effectively block) the statechart execution until the promise is fulfilled or rejected. This allows you to write statecharts that do not need so many intermediate states for tracking if a promise is fulfilled.

To enable async operations, pass :async? true when installing statecharts:

(scf/install-fulcro-statecharts! app {:async? true})

This swaps in the async processor and execution model from the Async Execution Engine section.

The com.fulcrologic.statecharts.integration.fulcro.async-operations namespace (aliased as afop) provides two layers of async support.

afop/load and afop/invoke-remote return operation maps that can be mixed into the same result vector as synchronous operations like ops/assign. The async execution engine detects these maps and awaits the underlying promise automatically:

;; Load data and set a flag — both in one expression result
(state {:id :loading-user}
  (on-entry {}
    (script {:expr (fn [env data]
                     [(ops/assign :loading? true)
                      (afop/load :user User {::sc/ok-event    :event/user-loaded
                                             ::sc/error-event :event/load-failed})])}))
  (transition {:event :event/user-loaded :target :viewing-user})
  (transition {:event :event/load-failed :target :error}))

afop/load parameters:

  • query-root — A keyword or ident for the root of the remote query

  • component-or-actor — A Fulcro component class, or a keyword naming a known actor

  • options:

    • ::sc/ok-event / ::sc/error-event — Events to raise on completion/failure

    • ::sc/ok-data / ::sc/error-data — Extra data to merge into the event

    • ::sc/target-alias — Target the load result at a statechart alias

    • Other df/load! options (:marker, :remote, etc.)

afop/invoke-remote parameters:

  • txn — A Fulcro transaction vector containing a single mutation

  • options:

    • :ok-event / :error-event — Events to raise on success/failure

    • :ok-data / :error-data — Extra data for the events

    • :target — Data-targeting path (vector, alias keyword, or actor-prefixed path)

    • :returning — Component class or actor keyword for normalization

    • :mutation-remote — Remote name (defaults to :remote)

    • :tx-options — Options passed to rc/transact!

;; Remote mutation with optimistic update
(state {:id :saving}
  (on-entry {}
    (script {:expr (fn [env data]
                     [(ops/assign :saving? true)
                      (afop/invoke-remote [(save-user (:form-data data))]
                        {:ok-event    :event/saved
                         :error-event :event/save-failed
                         :returning   User})])}))
  (transition {:event :event/saved :target :success})
  (transition {:event :event/save-failed :target :error}))
Promise Helpers (Advanced)

For complex custom expressions, afop/await-load and afop/await-mutation are imperative functions that return promesa promises directly. These are used internally by the operation-map handlers above, but are also available when you need full control.

Importantly, the async execution engine is not limited to Fulcro I/O — any function that returns a promise works. This makes it straightforward to integrate with the broader async JS ecosystem (fetch, IndexedDB, Web APIs, third-party SDKs) without needing extra intermediate states in your chart:

;; Using await-load for a Fulcro load
(script {:expr (fn [env data]
                 (afop/await-load env :user User
                   {::sc/ok-event    :event/user-loaded
                    ::sc/error-event :event/load-failed}))})

;; Using a plain promise for a fetch call
(script {:expr (fn [env data]
                 (p/let [response (js/fetch "/api/data")
                         json     (.json response)]
                   [(ops/assign :result (js->clj json :keywordize-keys true))]))})

await-load and await-mutation accept the same parameters as the operation-map constructors, plus an env argument (the statechart environment) as the first parameter.

Comparison: Send-based vs Async

With standard operations (fop/load), intermediate states are visible:

;; Using fop/load — chart rests in :loading until external event arrives
(state {:id :loading}
  (on-entry {}
    (script {:expr (fn [env data]
                     [(fop/load :user User {::sc/ok-event :event/loaded})])}))
  (transition {:event :event/loaded :target :viewing}))

With async operations (afop/load), the transition is atomic:

;; Using afop/load — atomic transition, never observably in :loading
(state {:id :loading}
  (on-entry {}
    (script {:expr (fn [env data]
                     [(afop/load :user User {::sc/ok-event :event/loaded})])}))
  (transition {:event :event/loaded :target :viewing}))
Note
The existing fop/load and fop/invoke-remote operations remain unchanged and continue to work with both sync and async execution engines.

7.4. Useful Helpers

7.4.1. Functions

(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 state chart session itself. This is useful for adding a lookup ref to a UI component whose rendering depends on changes to the state chart’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 state chart.

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

Returns the current configuration (active states) of the given state chart 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.

7.4.2. Content of data in Executable Content

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

  • The local data of the state chart (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 state chart

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

7.4.3. Runtime env of Executable Content

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 state chart Registry instance

  • ::sc/data-model - The state chart DataModel instance

  • ::sc/event-queue - The state chart EventQueue instance

  • ::sc/working-memory-store - The state chart working memory store

  • ::sc/processor - The state chart processing algorithm

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

  • ::sc/execution-model - The CLJC state chart ExecutionModel

7.5. React Hooks

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

The basic idea is that the state chart 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 state chart 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")))))

7.6. Hierarchical Routing

Note
The legacy com.fulcrologic.statecharts.integration.fulcro.ui-routes namespace is deprecated. All examples below use the new routing namespace. The old namespace remains available for backward compatibility but does not include URL sync, cross-chart routing, or configuration validation.

7.6.1. Why Statechart-Driven Routing?

Routing in a web application is fundamentally a state machine problem. You have a set of possible screens, transitions between them, guards that prevent certain transitions, and side effects (loading data) that must occur when entering a screen. Traditional routing libraries model this implicitly with callback chains, middleware stacks, and ad-hoc guard logic. Statecharts make it explicit.

With statechart-driven routing you get:

  • Guards and denial are first-class. A dirty form blocking navigation is not a bolt-on: it is a guard condition on the transition. The statechart naturally holds the "denied" state and lets you offer "Continue anyway?" or "Cancel" without juggling flags.

  • Async side effects on entry. When a user navigates to a project detail page, the statechart’s on-entry handler can fire a data load. Because this library supports async processing (see [Asynchronous Processing]), the load completes before the next event is processed. This eliminates the class of complexity where you have to model async tracking with states/transitions.

  • Composable child charts. An module of the app can be a self-contained statechart invoked by the parent routing chart. The module’s chart manages its own sub-routes, its own guards, and its own data loads — yet the parent chart sees it as a single state. This enables code splitting, module boundaries, and isolated testing.

  • Bidirectional URL sync. The URL is derived from the statechart configuration — not the other way around. Browser back/forward triggers a statechart event that goes through the same guard and transition logic as programmatic navigation.

Important
Routing works best with async statechart processing. When calling install-fulcro-statecharts!, pass :async? true so that on-entry handlers (which often trigger data loads) complete before the next event is processed. The demo code in this section assumes async mode.

7.6.2. End-to-End Setup Walkthrough

This section walks through every step to get routing working, from zero to a running application. The code mirrors the routing-demo2 example included in the source tree.

Step 1: Create the Fulcro Application
(ns app.main
  (:require
    [com.fulcrologic.fulcro.application :as app]
    ;; These requires pull in the async implementations -- needed for :async? true
    [com.fulcrologic.statecharts.algorithms.v20150901-async]
    [com.fulcrologic.statecharts.event-queue.async-event-loop]
    [com.fulcrologic.statecharts.execution-model.lambda-async]
    [com.fulcrologic.statecharts.integration.fulcro :as scf]
    [com.fulcrologic.statecharts.integration.fulcro.routing :as sroute]
    ;; Pull in browser history implementation (CLJS dead-code elimination safe)
    [com.fulcrologic.statecharts.integration.fulcro.routing.browser-history]))

(defonce app-instance
  (app/fulcro-app
    {:remotes {:remote (my-remote)}}))
Step 2: Install Statechart Infrastructure
(scf/install-fulcro-statecharts! app-instance
  {:async?      true
   :event-loop? true
   :on-save     (fn [session-id wmem]
                  ;; Required: drives state-to-URL synchronization
                  (sroute/url-sync-on-save session-id wmem app-instance))})

The :on-save callback fires every time a statechart session’s working memory is persisted. Calling sroute/url-sync-on-save inside it is what makes state-to-URL synchronization work. Passing app-instance as the third argument enables child chart URL tracking — when a child chart transitions, the URL updates to reflect the full nested path.

Step 3: Define and Start the Routing Chart
(sroute/start! app-instance my-routing-chart)

start! registers the chart under sroute/session-id and starts a session. No manual session ID or registration key management is needed.

Step 4: Install URL Synchronization
;; Store cleanup fn so shadow-cljs hot reload does not leak popstate listeners
(defonce url-sync-cleanup (atom nil))

;; In your init function:
(when-let [old-cleanup @url-sync-cleanup] (old-cleanup))
(reset! url-sync-cleanup
  (sroute/install-url-sync! app-instance))

install-url-sync! must be called after start!. It returns a cleanup function that removes all listeners — the defonce + atom pattern above ensures hot reloads do not accumulate stale popstate handlers.

On CLJS, a BrowserURLHistory provider is used by default. On CLJ, you must pass a :provider explicitly (see Headless Testing).

Step 5: Mount the UI
(app/set-root! app-instance ui/Root {:initialize-state? true})
(app/mount! app-instance ui/Root "app")
Complete init Function

Here is the full initialization sequence (simplified from routing-demo2/app.cljs):

(defn ^:export init []
  (app/set-root! app-instance ui/Root {:initialize-state? true})
  (scf/install-fulcro-statecharts! app-instance
    {:async?      true
     :event-loop? true
     :on-save     (fn [sid wmem]
                    (sroute/url-sync-on-save sid wmem app-instance))})
  ;; Register any child charts that istates reference by ID
  (scf/register-statechart! app-instance :app/admin-chart admin-chart/admin-chart)
  ;; Start main routing chart
  (sroute/start! app-instance chart/routing-chart)
  ;; Install URL sync (after start!)
  (when-let [old-cleanup @url-sync-cleanup] (old-cleanup))
  (reset! url-sync-cleanup (sroute/install-url-sync! app-instance))
  (app/mount! app-instance ui/Root "app"))

(defn ^:export refresh []
  (app/force-root-render! app-instance))

7.6.3. Defining Routes

A routing chart is built from four DSL functions: routing-regions, routes, rstate, and istate.

routing-regions

routing-regions wraps a routes node in a parallel state that includes a small internal state machine for route denial (the "routing info" region). This is what enables the denial modal pattern (see Busy Guards).

(sroute/routing-regions
  (sroute/routes {...}
    ...route states...))

The result is a state node :state/route-root containing a parallel node with two regions: the routing info state machine and your routes.

routes

routes is the container for all route states within a routing region. It generates transitions for every descendant :route/target and for every cross-chart target declared via :route/reachable on istate children.

(sroute/routes {:id :region/main :routing/root `ui/RoutingRoot}
  (sroute/rstate {:route/target `ui/Dashboard})
  (sroute/rstate {:route/target `ui/ProjectList :route/segment "projects"})
  (sroute/rstate {:route/target `ui/ProjectDetail :route/params #{:project-id}}))

Required options:

:id

The state ID for this routes node.

:routing/root

The Fulcro component that serves as the rendering parent for these routes. Must have a constant ident (e.g. [:component/id ::RoutingRoot]) or be the app root (no ident).

Optional:

:initial

The state ID of the initial route (defaults to first child).

rstate

rstate creates a route state node. When the statechart enters this state, three things happen automatically:

  1. Route parameters from the event data are stored in the data model at [:routing/parameters <state-id>].

  2. The target component is initialized in Fulcro’s state database (controlled by sfro/initialize).

  3. The parent component’s query is rewritten to include a join to the target, so the correct component renders.

(sroute/rstate {:route/target `ui/ProjectDetail
                :route/segment "detail"
                :route/params #{:project-id}})

Options:

:route/target

(Required) A component class or anything accepted by comp/registry-key→class. The state ID is automatically derived from this — do not pass an explicit :id. Prefer using the class directly (via syntax-quote, e.g. `ui/Dashboard) rather than a keyword symbol — using the class forces a require on the target namespace, so the compiler catches missing dependencies at build time. Use a keyword only when necessary to break circular requires.

:route/segment

(Optional) Custom URL path segment string. When absent, defaults to the simple name of the target (e.g. ProjectDetail). Used by the default URLCodec, which is customizable.

:route/params

(Optional) A set of keywords. When present in the routing event data, these keys are extracted and stored at [:routing/parameters <state-id>] in the data model.

:parallel?

(Optional) When true, this node is a parallel state.

You can nest rstate nodes to create hierarchical routes:

(sroute/routes {:routing/root `ui/Root :id :routes}
  (sroute/rstate {:route/target `ui/UserList :route/segment "users"}
    (sroute/rstate {:route/target `ui/UserDetail}
      (sroute/rstate {:route/target `ui/UserEdit :route/segment "edit"}))))

The full URL for UserEdit composes as /users/UserDetail/edit — each ancestor’s segment is prepended.

Segment Composition

Each route state contributes one URL path segment in the default URLCodec. By default the segment is the simple name of its :route/target. Use :route/segment to override with a human-friendly string.

Segments compose from the routing root down to the active leaf:

Route Target URL

Dashboard (no custom segment)

/Dashboard

ProjectList with :route/segment "projects"

/projects

ProjectDetail nested under ProjectList

/projects/ProjectDetail

UserEdit with :route/segment "edit" nested under UserDetail nested under UserList with :route/segment "users"

/users/UserDetail/edit

Route Parameters

In the default URL encoding route parameters are not encoded in URL path segments. Instead they travel through event data and are persisted to the URL via a single opaque _p query parameter:

;; Navigating with params:
(sroute/route-to! app ProjectDetail {:project-id 42})
;; URL: /projects/ProjectDetail?_p=<base64-encoded-transit>

To declare which keys should be extracted from event data:

(sroute/rstate {:route/target `ui/ProjectDetail :route/params #{:project-id}})

Parameters are then available in the data model at [:routing/parameters <state-id>], where <state-id> is the keyword derived from the :route/target.

You can install an alternate URLCodec to customize this behavior.

7.6.4. Component Setup Requirements

Route target components must follow certain conventions for the routing system to manage their state and rendering correctly.

Constant Ident

Every route target must have an ident. For singleton screens (dashboards, settings pages), use a constant ident:

(defsc Dashboard [this {:dashboard/keys [greeting]}]
  {:query         [:dashboard/greeting]
   :ident         (fn [] [:component/id ::Dashboard])
   :initial-state {:dashboard/greeting "Welcome!"}})

For entity-based screens (project detail, user profile), use a data-driven ident:

(defsc ProjectDetail [this {:project/keys [id name description]}]
  {:query [:project/id :project/name :project/description]
   :ident :project/id})

When using a data-driven ident, you will typically need sfro/initial-props to provide the ident key from event data (see Component Options Reference).

preserve-dynamic-query?

The routing system dynamically rewrites parent component queries to include a join to the active route target. Fulcro hot code reload resets queries to their original values — which would break active routes. To prevent this, every component that renders a subroute must set the option to preserve dynamic queries:

(defsc RoutingRoot [this {:ui/keys [current-route]}]
  {:query                   [:ui/current-route]
   :ident                   (fn [] [:component/id ::RoutingRoot])
   :preserve-dynamic-query? true   ;; REQUIRED
   :initial-state           {:ui/current-route {}}})
Rendering Subroutes

Use sroute/ui-current-subroute to render the active child in a standard (non-parallel) routing region:

(defsc RoutingRoot [this {:ui/keys [current-route]}]
  {:query                   [:ui/current-route]
   :ident                   (fn [] [:component/id ::RoutingRoot])
   :preserve-dynamic-query? true
   :initial-state           {:ui/current-route {}}}
  (dom/div {}
    (sroute/ui-current-subroute this comp/factory)))

For parallel routing regions, use sroute/ui-parallel-route with the target’s registry key:

(sroute/ui-parallel-route this ::SomeTarget comp/factory)
Navigation

Navigate programmatically using route-to!:

;; From a component's event handler:
(sroute/route-to! this ::ProjectList)

;; With route parameters:
(sroute/route-to! this ::ProjectDetail {:project-id 42})

;; From anywhere with access to the app:
(sroute/route-to! app ::Dashboard)

Browser back and forward (when URL sync is installed):

(sroute/route-back! app)
(sroute/route-forward! app)

7.6.5. Composed Routing with istate

istate is the mechanism for composing independently-defined statecharts into a routing hierarchy. It creates a route state that, when entered, invokes a child statechart on the target component.

This is powerful for several reasons:

  • Code splitting: The child chart can be in a separate namespace, loaded lazily.

  • Team boundaries: Different teams own different sub-charts without coordination.

  • Isolated testing: Each child chart can be tested in isolation.

  • Encapsulated lifecycle: The child chart manages its own sub-routes, data loads, and guards.

How istate Differs from rstate

An rstate is a simple route: entering it initializes a component and sets up a query join.

An istate does all of that AND invokes a child statechart. The child chart runs as a separate session, with its own configuration, data model, and event processing. When the parent exits the istate, the child session is terminated.

Co-located Charts (sfro/statechart)

The simplest way to associate a child chart with a component is to put it directly on the component:

(defsc AdminPanel [this {:ui/keys [current-route]}]
  {:query                   [:ui/current-route]
   :ident                   (fn [] [:component/id ::AdminPanel])
   :preserve-dynamic-query? true
   sfro/statechart          admin-chart/admin-chart    ;; <-- co-located chart
   :initial-state           {:ui/current-route {}}})

When the routing system enters this istate, it reads sfro/statechart from the component, registers it under the component’s registry key, and invokes it.

Pre-registered Charts (sfro/statechart-id)

For code splitting, register the chart separately and reference it by ID:

;; At startup:
(scf/register-statechart! app :my-app/admin-chart admin-chart/admin-chart)

;; On the component:
(defsc AdminPanel [this {:ui/keys [current-route]}]
  {:query                   [:ui/current-route]
   :ident                   (fn [] [:component/id ::AdminPanel])
   :preserve-dynamic-query? true
   sfro/statechart-id       :my-app/admin-chart    ;; <-- reference by ID
   :initial-state           {:ui/current-route {}}})

This is the pattern used in the demo application.

Cross-Chart Routing via :route/reachable

The parent chart needs to know which targets exist inside the child chart so it can generate transitions to them.

Auto-derivation (recommended): When the istate target component has a co-located chart via sfro/statechart, the reachable targets are automatically derived by walking the child chart’s route targets. You do not need to declare :route/reachable at all:

;; In the parent chart — reachable targets are auto-derived from AdminPanel's sfro/statechart:
(sroute/istate {:route/target `ui/AdminPanel})

This works because istate calls reachable-targets on the co-located child chart at chart construction time, collecting all :route/target values transitively (including nested child charts).

Note
Auto-derivation requires sfro/statechart on the target component (not sfro/statechart-id). If neither an explicit :route/reachable nor a resolvable sfro/statechart is found, istate throws an error at chart construction time.

Manual override: You can still provide an explicit :route/reachable set, which takes precedence over auto-derivation. This is useful when you want to do code splitting and don’t have the component available at initial base load.

;; Explicit reachable — good for code splitting
(sroute/istate {:route/target    `ui/AdminPanel
                :route/reachable #{::ui/AdminUsersList
                                   ::ui/AdminUserDetail
                                   ::ui/AdminSettings}})

With reachable targets (whether auto-derived or explicit), sroute/route-to! can navigate directly to any child from anywhere in the parent chart.

Two mechanisms handle this:

  1. Not yet in the owner istate: The parent transitions into the istate and stores the desired route as ::pending-child-route. When the child chart starts, its routes on-entry handler detects the pending route and raises it as an event.

  2. Already in the owner istate: The parent sends the route event directly to the child session via scp/send!. The child chart transitions internally without re-entering the istate.

The Child Chart

A child chart used with istate is just a normal routing chart. The key differences are:

  • Its :routing/root is the parent istate’s target component (e.g. `AdminPanel).

  • The ::pending-child-route mechanism is handled automatically by routes on-entry.

(ns app.admin-chart
  (:require
    [com.fulcrologic.statecharts.chart :as chart]
    [com.fulcrologic.statecharts.integration.fulcro.routing :as sroute]))

(def admin-chart
  (chart/statechart {:initial :admin/routes}
    (sroute/routes {:id :admin/routes :routing/root `ui/AdminPanel}
      (sroute/rstate {:route/target `ui/AdminUsersList})
      (sroute/rstate {:route/target `ui/AdminUserDetail :route/params #{:user-id}})
      (sroute/rstate {:route/target `ui/AdminSettings}))))
Advanced istate Options

The istate function accepts additional options for controlling the child statechart invocation lifecycle. These are less commonly needed but available for advanced use cases:

on-done

Transition target when the child chart reaches a final state.

exit-target

Target to transition to when the istate is exited (before the child chart is torn down).

invoke-params

Additional parameters to pass to the child chart invocation.

child-session-id

Override the auto-generated child session ID.

autoforward

When true, forwards all unhandled events to the child chart.

See the istate docstring in routing.cljc for full details.

send-to-self! for Child Chart Communication

When a component has a co-located child statechart (via istate), it can send events to the child session:

;; From the istate target component or any of its UI children:
(sroute/send-to-self! this :event/save {:some "data"})

This walks up the Fulcro component parent chain starting from this to find the nearest ancestor whose registry key has an invocation session ID. This means you can call send-to-self! from a child component rendered inside a route target — it will find the route target’s co-located chart automatically.

Inspecting Child State

To read the current configuration of a child chart from the route target or any of its UI children:

(sroute/current-invocation-configuration this)
;; => #{:admin/routes :region/routing-info ...}

Like send-to-self!, this walks the parent chain to find the nearest ancestor with a co-located chart.

sfro/actors

When the child chart is invoked, :actor/component is automatically set to the target component. If the child chart needs additional actors, declare them on the component:

(defsc AdminPanel [this props]
  {sfro/actors (fn [env data]
                 {:actor/sidebar (scf/actor Sidebar (comp/get-ident Sidebar {}))})
   ...})

7.6.6. Authentication Guard Pattern

A common requirement is redirecting unauthenticated users to a login screen, then returning them to their original destination after login. There is no built-in library code for this, because the elements allow you to build the pattern and customize it to your application’s needs via things that statecharts can already do.

The demo application (routing-demo2/chart.cljc) demonstrates this pattern.

Structure

The auth guard is a plain state (not rstate or istate) that wraps the login screen and an initializing state. It sits inside the routes node alongside the authenticated route states:

(sroute/routes {:id :region/main :routing/root `ui/RoutingRoot}
  ;; Auth guard: intercepts route-to.* when unauthenticated
  (state {:id :state/unauthenticated :initial :state/initializing}
    ;; Wildcard transition: catches ANY route-to.* event
    (transition {:event :route-to.* :target ::ui/LoginScreen}
      (script {:expr save-bookmark}))
    (state {:id :state/initializing}
      (on-entry {} (script {:expr check-session!}))
      (transition {:event :auth/session-valid :target ::ui/Dashboard})
      (transition {:event :auth/session-invalid :target ::ui/LoginScreen}))
    (sroute/rstate {:route/target `ui/LoginScreen}
      ;; Login transitions...
      ))

  ;; Authenticated routes (outside the unauthenticated state)
  (sroute/rstate {:route/target `ui/Dashboard}
    (on-entry {} (script {:expr replay-bookmark!})))
  (sroute/rstate {:route/target `ui/ProjectList :route/segment "projects"})
  ;; ...more routes...
  )
How the Bookmark Works
  1. The user bookmarks or deep-links to /projects/ProjectDetail?_p=…​.

  2. On page load, the statechart starts in :state/unauthenticated, which checks the session.

  3. If unauthenticated, the route-to.* wildcard transition fires first (document order), saving the original event as a bookmark.

  4. The user logs in successfully, transitioning to Dashboard.

  5. Dashboard’s on-entry replays the bookmark by raising the saved event via `senv/raise.

  6. The routing system processes the replayed event and navigates to the bookmarked destination.

The bookmark is stored as a data model assignment (ops/assign) and cleared after replay:

(defn- save-bookmark [_env _data event-name event-data]
  (let [login-event (sroute/route-to-event-name ::ui/LoginScreen)]
    (if (= event-name login-event)
      nil  ;; don't save login route as bookmark (avoids loops)
      [(ops/assign ::bookmark {:event-name event-name :event-data event-data})])))

(defn- replay-bookmark! [env data & _]
  (let [bookmark (get data ::bookmark)]
    (when bookmark
      (senv/raise env (:event-name bookmark) (:event-data bookmark)))
    ;; Clear bookmark after use
    [(ops/assign ::bookmark nil)]))

7.6.7. Pluggable URL Codec

URL encoding and decoding is handled by the URLCodec protocol defined in routing.url-codec:

(defprotocol URLCodec
  (encode-url [this context]
    "Given a context map, return a URL path string (with query if needed).
     Context keys: :segments, :params, :route-elements")
  (decode-url [this href route-elements]
    "Given a URL string and route-elements map, return {:leaf-id :params} or nil."))
Default: TransitBase64Codec

The default implementation (routing.url-codec-transit) encodes route parameters as transit, then base64, into a _p query parameter. URL shape: /Seg1/Seg2?_p=<base64-encoded-transit>.

This is created automatically by install-url-sync! if you do not pass a :url-codec option.

Custom Codec

To implement a custom codec (e.g. human-readable query params, path-based params), implement the URLCodec protocol:

(require '[com.fulcrologic.statecharts.integration.fulcro.routing.url-codec :as ruc])

(def my-codec
  (reify ruc/URLCodec
    (encode-url [_ {:keys [segments route-elements params]}]
      ;; Build path from segments
      (let [seg-strs (mapv (fn [state-id]
                             (ruc/element-segment (get route-elements state-id)))
                       segments)
            path     (str "/" (clojure.string/join "/" seg-strs))]
        ;; Add params however you prefer
        (if (seq params)
          (str path "?" (my-param-encoder params))
          path)))
    (decode-url [_ href route-elements]
      ;; Parse href, find matching leaf, extract params
      ...)))

;; Pass to install-url-sync!
(sroute/install-url-sync! app {:url-codec my-codec})

The ruc/element-segment helper returns :route/segment if present, otherwise (name (:route/target element)).

7.6.8. Busy Guards

Busy guards prevent navigation away from a route when work would be lost — for example, an unsaved form.

How It Works

When routes generates transitions for each route target, it also generates a catch-all route-to. transition with sroute/busy? as its guard condition. Because statecharts evaluate transitions in document order, this catch-all fires *before the specific target transitions when the guard returns true.

The sroute/busy? function performs a deep recursive check:

  1. For each active route state, it checks if the target component has a sfro/busy? predicate or is a dirty Fulcro form.

  2. If the route state has an invoked child chart, it recursively checks the child chart’s active states.

  3. If any component at any depth returns busy, the route change is denied.

Adding a Busy Predicate

Add sfro/busy? to any route target component:

(defsc BusyForm [this {:ui/keys [notes]}]
  {:query         [:ui/notes]
   :ident         (fn [] [:component/id ::BusyForm])
   :initial-state {:ui/notes ""}
   sfro/busy?     (fn [_app {:ui/keys [notes]}]
                    (and (string? notes) (pos? (count notes))))})
Denial Lifecycle

When a route transition is denied:

  1. record-failed-route! stores the failed event in the data model.

  2. The routing info region transitions to :routing-info/open.

  3. sroute/route-denied? returns true.

  4. Your UI renders a confirmation dialog.

  5. The user chooses one of:

    • (sroute/force-continue-routing! app) — resends the failed event with a ::force? flag that bypasses the busy check.

    • (sroute/abandon-route-change! app) — closes the routing info and stays on the current route.

;; In your Root component's render:
(when (sroute/route-denied? this)
  (dom/div {} ; possibly render as a modal, etc. Use ui props to optionally customize messages. None of that needs statechart specifics.
    (dom/p {} "You have unsaved changes. Leave anyway?")
    (dom/button {:onClick #(sroute/force-continue-routing! this)} "Leave")
    (dom/button {:onClick #(sroute/abandon-route-change! this)} "Stay")))
URL Sync Interaction with Denial

When URL sync is installed and the user presses the browser back button:

  1. The popstate listener fires, sending a route event.

  2. If the route is denied, install-url-sync! detects the mismatch between the statechart state and the browser URL.

  3. It automatically restores the URL to its pre-navigation value (using go-forward! or go-back! depending on direction).

  4. If an :on-route-denied callback was provided to install-url-sync!, it is called with the denied URL.

7.6.9. Headless Testing

The SimulatedURLHistory provider enables full routing testing without a browser. It is cross-platform (CLJ + CLJS) and works in kaocha, REPL, and CI environments.

Test Setup
(require '[com.fulcrologic.fulcro.application :as app]
         '[com.fulcrologic.fulcro.components :as comp :refer [defsc]]
         '[com.fulcrologic.statecharts.integration.fulcro :as scf]
         '[com.fulcrologic.statecharts.integration.fulcro.routing :as sroute]
         '[com.fulcrologic.statecharts.integration.fulcro.routing.simulated-history :as rsh]
         '[com.fulcrologic.statecharts.integration.fulcro.routing.url-history :as ruh])

;; Minimal route components
(defsc RootComp [_ _]
  {:query [:ui/current-route]
   :ident (fn [] [:component/id ::root])
   :initial-state {:ui/current-route {}}})

(defsc PageA [_ _]
  {:query [:page/id]
   :ident :page/id
   :initial-state {:page/id :a}})

(defsc PageB [_ _]
  {:query [:page/id]
   :ident :page/id
   :initial-state {:page/id :b}})

;; Create headless app with synchronous processing
(defn test-app []
  (let [a (app/fulcro-app)]
    (app/set-root! a RootComp {:initialize-state? true})
    (scf/install-fulcro-statecharts! a {:event-loop? false})
    a))
Testing Programmatic Navigation
(let [app      (test-app)
      provider (rsh/simulated-url-history "/")]
  (sroute/start! app my-routing-chart)
  (scf/process-events! app)
  (let [cleanup (sroute/install-url-sync! app {:provider provider})]
    (sroute/url-sync-on-save sroute/session-id nil app)

    ;; Navigate
    (sroute/route-to! app `PageB)
    (scf/process-events! app)
    (sroute/url-sync-on-save sroute/session-id nil app)

    ;; Assert URL and route state
    (assert (= "/PageB" (ruh/current-href provider)))
    (assert (= 2 (count (rsh/history-stack provider))))

    (cleanup)))
Testing Browser Back/Forward (CLJ Only)

Browser back/forward requires synchronous event processing, so these tests must be CLJ-only:

;; After programmatic navigation A -> B -> C:
(ruh/go-back! provider)
(scf/process-events! app)
(sroute/url-sync-on-save sroute/session-id nil app)
;; Now on page B; history stack unchanged

(ruh/go-forward! provider)
(scf/process-events! app)
(sroute/url-sync-on-save sroute/session-id nil app)
;; Back on page C
Testing Route Denial
;; Navigate to a busy page, then try to go back
(ruh/go-back! provider)
(scf/process-events! app)
(sroute/url-sync-on-save sroute/session-id nil app)

(assert (true? (sroute/route-denied? app)))
;; URL is restored to the busy page's URL

;; Force continue
(sroute/force-continue-routing! app)
(scf/process-events! app)
(scf/process-events! app)  ;; extra round for re-queued event
(sroute/url-sync-on-save sroute/session-id nil app)

(assert (false? (sroute/route-denied? app)))
SimulatedURLHistory Inspection Helpers
(rsh/history-stack provider)

Returns a vector of URL strings.

(rsh/history-cursor provider)

Returns the current cursor position (zero-based index).

(rsh/history-entries provider)

Returns the full entries vector with :url and :index keys.

See url_sync_headless_spec.cljc in the test suite for comprehensive examples covering cross-chart routing, custom segments, and denial recovery.

7.6.10. API Reference

Chart Construction
Function Description

sroute/routing-regions

Wraps routes in parallel state with denial modal support.

sroute/routes

Container for route states. Generates transitions for all targets.

sroute/rstate

Route state. On-entry: params, init, query rewrite.

sroute/istate

Route state that invokes a child statechart. Auto-derives :route/reachable from sfro/statechart when not explicitly provided.

Lifecycle
Function Description

sroute/start! [app chart]

Registers chart and starts routing session.

sroute/start! [app chart opts]

Same, with :routing/checks option.

sroute/install-url-sync! [app]

Installs URL sync with defaults. Returns cleanup fn.

sroute/install-url-sync! [app opts]

With :provider, :url-codec, :prefix, :on-route-denied, :routing/checks.

Navigation
Function Description

sroute/route-to! [app-ish target]

Navigate to target (class or keyword).

sroute/route-to! [app-ish target data]

Navigate with route params.

sroute/route-back! [app-ish]

Browser back via URL sync provider.

sroute/route-forward! [app-ish]

Browser forward via URL sync provider.

sroute/send-to-self! [this event data]

Send event to nearest ancestor’s co-located child chart.

Guard and Denial
Function Description

sroute/busy? [env data]

Deep recursive busy check (guard condition). Component-level sfro/busy? receives (app props).

sroute/route-denied? [app-ish]

True if routing info is in open/denied state.

sroute/force-continue-routing! [app-ish]

Override guard and retry denied route.

sroute/abandon-route-change! [app-ish]

Cancel denied route, close routing info.

State Inspection
Function Description

sroute/active-leaf-routes [app-ish]

Set of active leaf route state IDs (cross-chart).

sroute/reachable-targets [chart]

Set of all reachable target keywords in a chart, including transitive targets from co-located child charts. Useful for debugging and inspection.

sroute/current-invocation-configuration [this]

Child chart’s configuration from this or nearest ancestor with a co-located chart.

sroute/has-routes? [chart id]

True if state contains routes (single chart).

sroute/leaf-route? [chart id]

True if state is a leaf route (single chart).

Rendering
Function Description

sroute/ui-current-subroute [this factory-fn]

Render active child in standard routing region.

sroute/ui-parallel-route [this key factory-fn]

Render one child in a parallel routing region.

URL Sync Helpers
Function Description

sroute/url-sync-on-save [sid wmem app]

Call from :on-save to drive URL sync.

sroute/url-sync-provider [app-ish]

Returns installed URLHistoryProvider.

sroute/url-sync-installed? [app-ish]

True if URL sync is active.

sroute/route-current-url [app-ish]

Current URL from provider.

sroute/route-history-index [app-ish]

Current history index from provider.

sroute/route-sync-from-url! [app-ish]

Re-parse current URL and route to match.

Constants
Var Description

sroute/session-id

The well-known session ID (::session) for routing.

Utilities
Function Description

sroute/route-to-event-name [target]

Converts a target (class or keyword) to the :route-to.<fqn> event keyword. Useful in auth guard patterns.

Note
Functions like route-to!, route-back!, and others accept either a Fulcro app instance or a component instance (referred to as app-ish in the source). Use whichever is convenient in your context.

7.6.11. Component Options Reference

Route target components can declare these options via rc/component-options (namespace alias sfro for routing-options):

sfro/initialize

One of :once (default), :always, or :never. Controls when the routing on-entry handler initializes the target component’s state. Forms default to :always; reports and plain components default to :once.

sfro/initial-props

A (fn [env data] props-map) that returns initial state for the route target. Defaults to rc/get-initial-state. Required when the target has a dynamic ident that depends on event data.

sfro/busy?

A (fn [app props] boolean?) predicate where app is the Fulcro app and props are the pre-resolved UI props of the component. When true, routing away from this target is denied. Forms with form-state are auto-guarded (dirty form = busy).

sfro/statechart

A co-located statechart definition. Auto-registered using the component’s registry key when the istate is entered. Also enables auto-derivation of :route/reachable targets — istate walks this chart to discover all route targets transitively.

sfro/statechart-id

The ID of a pre-registered statechart to invoke when the component is routed to (alternative to sfro/statechart).

sfro/actors

A (fn [env data] actors-map) returning additional actors for the co-located statechart. The :actor/component actor is always set automatically.

7.6.12. Route Configuration Validation

start! and install-url-sync! accept a :routing/checks option that controls how route configuration problems are reported:

;; Default: warnings are logged but execution continues
(sroute/start! app routing-chart)

;; Strict mode: throws ex-info on any configuration problem
(sroute/start! app routing-chart {:routing/checks :strict})

The validator detects:

  • Duplicate leaf names — two route targets whose URL segment (:route/segment or (name target)) collides, causing ambiguous URL matching.

  • Duplicate segment chains — two leaf routes with identical full segment paths (e.g. both resolve to /users/edit).

  • Reachable collisions — a :route/reachable target (whether auto-derived or explicit) whose simple name collides with a direct route target.

  • Invalid routing root — a routes node with a nil or non-coercible :routing/root.

Tip
Use :routing/checks :strict during development to catch configuration problems as thrown exceptions rather than logged warnings.

7.6.13. Routing Invariants

The following invariants hold for statechart-driven routing:

Constant ident roots

The :routing/root component MUST have a constant ident (e.g. [:component/id ::Root]). A nil second element in the ident is an error and the routing entry handler will log an error and skip query rewriting. The app root (no ident) is also acceptable.

Dynamic query preservation

Route target parents MUST set :preserve-dynamic-query? true in their component options. Without this, Fulcro hot code reload will reset the dynamically-rewritten query, breaking the active route’s rendering.

Unique URL segments

Each route target’s URL segment (:route/segment or the simple name of :route/target) must be unique within its routing region. Duplicate segments cause ambiguous URL-to-route matching. Use :routing/checks :strict during development to catch these early.

Guard evaluation order

When a route transition is attempted, sroute/busy? is evaluated first. If any active route target (at any depth in the invocation tree) returns busy, the transition is denied and record-failed-route! stores the event for potential retry via force-continue-routing!.

Single routing session

All routing goes through the single sroute/session-id session. Parallel regions within the routing chart handle multiple independent routing areas — do not create multiple routing sessions.

State ID derivation

Both rstate and istate derive their :id from :route/target. Passing an explicit :id throws an exception. This ensures state IDs and route targets are always consistent.

7.6.14. Common Gotchas and Troubleshooting

Route renders blank / no props

The most common cause is a missing :preserve-dynamic-query? true on the parent component that renders ui-current-subroute. Without it, Fulcro hot reload resets the query, losing the join to the active route target.

Also verify that the :routing/root component has a constant ident. A nil ident second element causes the routing entry handler to skip query rewriting entirely.

Route transition does nothing

Check whether the route is being denied by a busy guard. Call (sroute/route-denied? app) to verify. If the route is denied, either clear the busy condition or use force-continue-routing!.

Also ensure the target is reachable: for targets inside a child chart, the parent’s istate must have reachable targets (auto-derived from sfro/statechart or explicitly declared via :route/reachable). Use (sroute/reachable-targets chart) to inspect what targets are visible.

URL does not update after navigation

Verify that:

  1. sroute/url-sync-on-save is being called from the :on-save handler of install-fulcro-statecharts!.

  2. install-url-sync! was called after start!.

  3. The app instance passed to url-sync-on-save is the same one passed to install-url-sync!.

Bookmarked URL does not restore after login

The auth guard pattern (see above) requires that:

  1. The route-to. wildcard transition is *before the specific login route transition in document order.

  2. The save-bookmark function skips saving when the event targets the login screen itself (to avoid infinite loops).

  3. The replay-bookmark function uses senv/raise to re-inject the saved event.

Child chart routes show wrong URL

When URL sync is installed, pass app (not just session-id) to url-sync-on-save. This enables the parent chain lookup that delegates child session saves to the root session’s URL handler. Without it, child chart transitions do not update the URL.

Hot reload breaks routing

Use the defonce + cleanup atom pattern shown in the setup section. Without cleanup, each hot reload adds another popstate listener, causing duplicate event processing.

install-url-sync! throws "no routing session found"

install-url-sync! must be called after start!. It reads the routing session’s working memory to find the statechart registration key.

8. Testing

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):

(ns com.fulcrologic.statecharts.testing-spec
  (:require
    [com.fulcrologic.statecharts.chart :as chart]
    [com.fulcrologic.statecharts.elements :refer [state transition]]
    [com.fulcrologic.statecharts.testing :as testing]
    [fulcro-spec.core :refer [=> assertions component specification]]))

(defn is-tuesday? [env data] false)

(def some-statechart
  (chart/statechart {}
    (state {:id :state/start}
      (transition {:cond   is-tuesday?
                   :event  :event/trigger
                   :target :state/next}))
    (state {:id :state/next})))

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

(specification "My Machine"
  (component "When it is tuesday"
    (let [env (testing/new-testing-env (config) {is-tuesday? true})]

      (testing/start! env)
      (testing/run-events! env :event/trigger)

      (assertions
        "Checks to see that it is tuesday"
        (testing/ran? env is-tuesday?) => true
        "Goes to the next state"
        (testing/in? env :state/next) => true)

      (testing/goto-configuration! env [] #{:state/start})

      (assertions
        "Can be forced into a state"
        (testing/in? env :state/start) => true))))

8.1. Mocking

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.

8.2. Event Sends/Cancels

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

8.3. Starting in a Specific Configuration

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 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 state chart 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
    ))

9. Relationship to SCXML

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. The flow control XML nodes from the standard (if, elseif, else, foreach) have native Clojure function equivalents (see Flow Control Elements). Some other XML nodes are abbreviated or handled differently, since 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).

9.2. Conformance

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).