- 1. Aliases
- 2. Derived State Machines
- 3. Plugins
- 4. States
- 5. Writing Handlers and Data Manipulation
- 6. Using State Machines from the UI
- 7. Mutations and State Machines
- 8. Loads and State Machines
- 9. Timer Events
- 10. Sending Events FROM one State Machine to Another
- 11. Aborting Loads and Mutations
- 12. Full Example
- 13. Parting Thoughts
User interfaces are full of interactions that beg for the help of state machines. Just a simple login form is usually quite difficult to code (independent of tools) because its behavior depends on a number of factors…factors that are actually quite easy to represent with state machines, but get ugly quickly when spread out in various code artifacts.
This is not a new idea. People have been using state machines to control user interfaces and graphics systems for decades. For some reason most UI libraries and frameworks don’t usually have a well-known or particularly general-use state machine toolkit. Part of the problem is that a state machine needs to interact with actual UI state and vice-versa, so it is difficult to "bolt on" something, and there is often a lot of "glue code" to make the two hang together properly.
It turns out that Fulcro’s approach to UI is quite easily amenable to state machines because it has the following facets:
-
Application state is just data.
-
The application database is normalized. It is very easy to describe where particular bits of data are in a non-ambiguous manner.
-
The UI refresh is based on the normalized data model, and not the UI structure. Triggering refreshes requires only that you know what data your changing.
Thus, it turns out to be quite easy to build a state machine system for Fulcro with the following properties:
-
The state machine doesn’t need not know anything about the UI
-
The UI only needs to support displaying the declared state of the state machine.
-
Simple aliasing can map the state machine "values" onto Fulcro database values.
-
The aliasing makes it possible to re-use state machines on UIs that have varying shapes, and need not even name their Fulcro state according to the state machine’s conventions.
-
State machines can be instanced, so that more than one of the same kind can be running at once.
-
Active state machine data is stored in Fulcro’s app database, so it honors all history properties (e.g. support viewer, etc.)
-
Any number of simultaneous state machines of varying type can be running at once (even on the same component).
-
The state machine declarations are reusable, and make it easy to "derive" new definitions based on existing ones.
The first powerful concept for our state machines is aliasing. The first kind of aliasing is for the "actors" that will participate in our UI. An actor is simply a keyword defined in the state machine declaration, and is meant to stand for "some UI component". The actions of a state machine can then be written to abstractly refer to that component without actually needing to know anything else about it:
The uism
alias is for the ui-state-machine
namespace.
(defstatemachine login
{::uism/actor-names #{:dialog :form}
...})
In this example we plan to have a "dialog" and a "form" on the UI. These could be separate UI components, or could be the same. It doesn’t matter to the state machine!
The next layer of aliasing is for the data our state machine will manipulate:
(defstatemachine login
{::uism/actor-names #{:dialog :form}
::uism/aliases {:visible? [:dialog :ui/active?]
:login-enabled? [:form :ui/login-enabled?]
:busy? [:form :ui/busy?]
:error [:form :ui/login-error]
:username [:form :user/email]
:password [:form :user/password]}
...})
These aliases are based, as you can see, on the actor names. :visible?
, for example,
is an alias for the :dialog
actor’s :ui/active?
property. This mapping can be easily overriden
in a "derived" state machine by simple data manipulation:
(defstatemachine my-login
(assoc-in login [::uism/aliases :visible?] [:dialog :ui/open?]))
This makes it possible to easily build a library of state machines that work on your app state in a very general and configurable way without having to change any actual logic!
In order for a state machine to be as reusable as possible we’d also like to be able to write logic that the state machine uses in a form that can be easily changed. We call these bits of logic "plugins". The are simply functions that will receive a map of the current UI state (by alias name) and will do some calculation. They are meant to be side-effect free calculations.
In a login form we usually don’t want them to be able to press "Login" (or enter) until both username and password fields have something in them. If the username is an email we might also want to check that it looks like a valid email before allowing submission.
The state machine can come with a simple plugin like this:
::uism/plugins {:valid-credentials? (fn [{:keys [username password]}]
(and (seq username) (seq password)))}
that just checks that the fields are non-empty, and someone could easily provide an alternative implementation with the trick shown for overriding an alias in Derived State Machines.
The final bit of a state machine definition is, of course, the actual states. In our system we define these as a map from user-defined state name to a function that will receive the running state machine environment for all events triggered on that state machine.
The states must include an :initial
state, whose handler will be invoked with a
::uism/started
event when the state machine is first started. The "current state"
handler is always invoked for each event that is triggered while it is active, but
only the :initial
state sees a ::uism/started
event.
The overall configuration of states looks like this:
::uism/states {:initial { ... } ; REQUIRED
:state-id { ... }
:state2-id { ... }
and you have two options for what you put in a state’s definition.
With this option you specify a map of events to a description of what should happen:
::uism/states {:initial {::uism/events
{:thing-happened! {::uism/event-predicate (fn [env] ... true)
::uism/target-state :next-state
::uism/handler (fn [env] env)}}}
:next-state {...}
...
In this case the event :thing-happened!
is the an event that can happen while in the :initial
state. If that
event occurs, the following things are done:
-
If there is an event predicate, it is run. The default predicate is
(constantly true)
. If the predicate returnsfalse
then the event is ignored and nothing else happens. -
If the predicate returned true (or didn’t exist), then the handler is run. Any effects it has on
env
are propagated. -
If the predicate returned true and there is a
target-state
, then that target state will be activated.
ℹ️
|
You can use (uism/activate :state) in the handler , but that will prevent tools that try to
do things like diagram your state machine from seeing that transition. If you use both target-state and
activate in the handler then the handler wins. This is typically used when you’d like a given (exceptional) condition
to cause the state machine to finish and exit (or go to some alternate state).
|
The predicate is useful for a few reasons:
-
You may have a condition that should short-circuit triggers of numerous events. Without the predicate you’d have to code an
if
into each handler. -
The helper functions that set state (e.g.
set-string!
) apply state changes before your handler. Under certain circumstances you’d like to avoid that. If predicate isfalse
, then these events (as per the rules above) are not applied.
This format of defining the states allows you to write just one function, but is not normally recommended, as it does not give you the ability to analyze the states/events as a diagram via simple data analysis. It does, however, allow you complete flexibility with how the state machine is defined, so you are welcome to use it. Basically you do not define an event map, and instead embed a handler in it’s place:
::uism/states {:initial
{::uism/handler
(fn [env]
(log/info "Initial state.")
...)}}
From here it’s pretty easy. The handlers are functions that receive a state machine (SM) environment and must return a SM environment (or nil, which is considered "no change"). Since the environment is an immutable value, you will typically thread a sequence of these together to end up with a final result to return from the handler:
(fn [env]
(-> env
(uism/assoc-aliased :visible? true)
...))
The library includes functions for dealing with Fulcro state via the aliases we’ve defined:
(uism/assoc-aliased env alias new-value & more-kv-pairs)
-
Sets Fulcro state associated with the given alias to the given new value. Can accept multiple k-v pairs (like
assoc
). (uism/dissoc-aliased env alias & more-aliases)
-
Removes given aliases from Fulcro state. Can accept multiple aliases (like
dissoc
). (uism/update-aliased env alias f & args)
-
Updates given aliases in Fulcro state with function f and given arguments. (like
update
). (uism/integrate-ident env ident & named-parameter)
-
Integrates idents (append or prepend) to aliases in Fulcro state that refer to a list of idents. (like
fulcro.client.mutations/integrate-ident*
). (uism/remove-ident env ident alias)
-
Removes ident from aliases that refer to a list of idents, in Fulcro state. (like
fulcro.client.mutations/remove-ident*
). (uism/alias-value env alias)
-
Gets the current Fulcro state value associated with the alias.
(uism/run env plugin-name)
-
Runs the given plugin (passing it all of the aliased data from current Fulcro state) and returns the value from the plugin.
(uism/activate env state-name)
-
Returns a new env with
state-name
as the new active state. (uism/exit env)
-
Returns a new env that will end the state machine (and GC it’s instance from Fulcro state) after the results of the handler are processed.
(uism/store env k v)
-
Saves a state-machine local value. Useful for keeping track of some additional bit of data while your state machine is running.
(uism/retrieve env k)
-
Get state-machine local value.
There are numerous other helpers, including:
(uism/apply-action env (fn [state-map] state-map))
-
use a fn of state-map to apply some mutation helper via a SM env. The return value of the function will become the new state in the env and will be applied when the handler returns.
(uism/get-active-state this asm-id)
-
Read the “current state name” from an active state machine while in a UI component (e.g. via
this
). (uism/asm-value env ks)
-
Get the value of an ASM based on keyword OR key-path
ks
. (uism/actor→ident env actor-name)
-
Get the ident of an actor
(uism/actor-path env actor-name)
-
Get the real Fulcro state-path for the entity of the given actor.
(uism/actor-path env actor-name k)
-
Get the real Fulcro state-path for the attribute k of the entity of the given actor.
(uism/set-actor-value env actor-name k v)
-
Set a value in the actor’s Fulcro entity. Only the actor is resolved. The k is not processed as an alias.
(uism/actor-value env actor-name k follow-idents?)
-
Get the value of a particular key in the given actor’s entity. If follow-idents? is true (which is the default), then it will recursively follow idents until it finds a non-ident value.
The next step, of course, is hooking this state machine up so it can control your UI (which really just means your app state).
The first thing you need to do is create an instance and start it:
(uism/begin! component machine-def instance-id actor-map)
-
Installs an instance of a state machine (to be known as
instance-id
), based on the definition inmachine-def
, into Fulcro’s state and sends the::uism/started
event.
The actor-map
is a map keyed by actor-id that lets the state machine know what components in your Fulcro app are being
acted upon. It also supplied the necessary information that is needed when doing remote mutations
that return values and loads (since a component class or instance is needed to figure out normalization).
The actor map values must be one of the following:
- An ident
-
In this case the actor must not be used with mutations that return a value or loads.
- A component class
-
In this case the actor is assumed to be a singleton. The ident will be derived by calling
(prim/get-ident class {})
. This actor will work properly with remote return values and loads. - A component instance (e.g.
this
) -
A component instance can be found using the Fulcro indexer (e.g.
(prim/ref→any reconciler [:person/by-id 1])
). A component instance is sufficient for the state machine to find the corrent ident and query for the UI component, so it will work with loads/mutations.
For example, to start the above state machine with an instance ID of ::loginsm
:
(uism/begin! this login-machine ::loginsm {:dialog Dialog
:session Session
:form LoginForm})
In this example all three of our components are singletons whose idents are constant. If you are working
with actors that are live you either need to use a react instance (such as this
), or an explicit ident:
(uism/begin! this person-editing-machine ::personsm {:person [:person/by-id 3]
:editor this
:dialog Dialog})
If you plan to use mutations or loads against an actor that is specified with an explicit ident you will need to tell the state machine system what Fulcro component class is used for normalization:
(uism/begin! this person-editing-machine ::personsm {:person (uism/with-actor-class [:person/by-id 3] Person)
:editor this
:dialog Dialog})
Failing to do so may cause your state machine to misbehave (the state machine actually tries to derive the class from Fulcro’s indexes, but that will only work if the component is on-screen).
If you do not know the ident of an actor when the machine begins, or if the ident of an actor can
change over time, then use reset-actor-ident
.
Say you start the machine like this, with a :none
marker.
(uism/begin! this person-editing-machine ::personsm {:selected-person (uism/with-actor-class [:person/by-id :none] Person)
:list [:person-list :singleton})
Then for example in an event you can update the actor’s ident like so
:person-selected-event
{::uism/handler (fn [{{:keys [new-ident]} ::uism/event-data :as env}]
(uism/reset-actor-ident env :selected-person new-ident))}
Now that you have a state machine running it is ready to receive events. It will have already run the initial state handler once, which means it will have already set up the state in such a way that it is possible for your UI to look correct. For example, in our login case the initial state shows the dialog, clears the input fields, and makes sure the logins are disabled.
Forms will commonly want to send a ::uism/value-changed
event to indicate that a value is changing.
Because this is such a common operation, there are easy helpers for it. For example, to
update a string:
(uism/set-string! component state-machine-id data-alias event-or-string)
-
Puts a string into the given data alias (you can pass a string or a DOM onChange event).
(uism/set-value! component state-machine-id data-alias raw-value)
-
Puts a raw (unmodified) value into the given data alias.
You can define other "custom" events to stand for whatever you want (and they can include aux data that you can pass along to the handlers). To trigger any kind of event use:
(uism/trigger! comp-or-reconciler state-machine-id event)
-
Trigger an arbitrary event on the given state machine.
For example:
(uism/trigger! reconciler ::loginsm :failure)
would send a (user-defined) :failure
event. Event data is just a map that can be passed as an
additional parameter:
(uism/trigger! reconciler ::loginsm :failure {:message "Server is down. Try in 15 minutes."})
Functions are included to trigger remote mutations. The state machine handlers are already an implementation of the optimistic side of a mutation, so really what we need is a way to trigger a remote (pessimistic) mutation and trigger events based on the outcome.
In the state machine system all mutations are run through as pessimistic mutations (see the documentation for the
namespace pessimistic-mutations.cljc
).
This means that you can receive ok/error results, and can easily merge return values.
The trigger-remote-mutation
function does this. It takes:
-
env
- The SM handler environment -
actor
- The name (keyword) of a defined actor. The mutation will be run in the context of this actor’s state (seepm/pmutate!
), which means that progress will be visible there. THERE MUST BE A MOUNTED COMPONENT with this actor’s name ON the UI, or the mutation will abort. This does not have to be the same component as you’re (optionally) returning from the mutation itself. It is purely for progress UI. -
mutation
- The symbol (or mutation declaration) of the server mutation to run. This function will not run a local version of the mutation. -
options-and-params
- The parameters to pass to your mutation. This map can also include these additional options:
ℹ️
|
The mutation system never assumes the data type of a return value |
::pm/returning Class
-
Option of pmutate to supply a component for normalizing the returned result. Use
(actor-class actor-name)
to get the correct class for an actor. ::pm/target explicit-target
-
Option of pmutate for targeting retuned result.
::uism/target-actor actor
-
Helper that can translate an actor name to a target, if returning a result.
::uism/target-alias field-alias
-
Helper that can translate a data alias to a target (ident + field).
::uism/ok-event event-id
-
The SM event to trigger when the pessimistic mutation succeeds (no default).
::uism/error-event event-id
-
The SM event to trigger when the pessimistic mutation fails (no default).
::uism/ok-data map-of-data
-
Data to include in the event-data on an ok event
::uism/error-data map-of-data
-
Data to include in the event-data on an error event
::uism/mutation-remote
-
The keyword name of the Fulcro remote (defaults to :remote)
The pessimistic mutation response (independent of targeting and such) will be merged into the ::uism/event-data
that is sent in the SM handler env
, which means the ok-event
and error-event
handlers can simply look in event-data for
the data sent back from the server.
This function does not side effect. It queues the mutation to run after the handler exits."
❗
|
If you need to return a class type that is not one of your actors, then you should add an actor to represent it (even if you do no other manipulation for it). That will keep your state machine code decoupled from your UI code, which will prevent circular references and state machine code reuse. |
The API includes these functions for doing loads in the context of a running state machine:
(load env k component-class params)
-
Just like Fulcro’s load, but takes a SM env. Use
actor-class
to get a component class of an actor. (load-actor env actor-name params)
-
(Re)load the given actor.
The params
of these functions can include most of the normal Fulcro load options (such as marker, which
defaults to false
for state machines), along with these special values:
:fulcro.client.primitives/component-class
-
A component class. Only used on
load-actor
, and only if the actor isn’t on-screen. Generally do not use. See note below. ::uism/post-event
-
An event to send when the load is done (instead of calling a mutation)
::uism/post-event-params
-
Extra parameters to send as event-data on the post-event.
::uism/fallback-event
-
The event to send if the load triggers a fallback.
::uism/fallback-event-params
-
Extra parameters to send as event-data on a fallback.
ℹ️
|
The helper function (uism/actor-class actor-name) can be used to retrieve the known Fulcro component
class for an actor (if available). This should always be used in preference to the component-class option above
since the helper function does not couple your state machine code to UI code.
|
Many UI interactions work better with some kind of timeout. For example, you don’t want to issue a load on an autocomplete search field until the user stops typing for 300ms, or perhaps you’d like to close a dialog and show an error if a data load takes more than 5 seconds.
The (uism/set-timeout env timer-id event-id event-data timeout cancel-on-events)
function can be used
in a handler to schedule a ms timer, where timer-id
is a user-invented name for the timer (keyword),
the event-id
is the invented keyword for the event you want to send, event-data
is additional
data you’d like to send with the event, and timeout
is in ms.
The cancel-on-events
parameter is a function that will be sent the name of any event that occurs while the timeout is
waiting. If it returns true
then the timeout will be auto-cancelled.
You can also explcitly cancel a timeout with `(uism/clear-timeout! env timer-id)
Version 0.0.21+ includes support for sending events from one state machine to another. The mechanism
for doing so is the trigger
method (no !
). It takes and returns a handler env
, and is composed
into the threading of env
in your handlers:
(fn [env]
(-> env
(uism/trigger :state-machine-id :event-id {:data :map})
...))
This has the effect of queueing the event for after the current handler has finished.
State machines that trigger events may cause handlers to run that themselves trigger further events. The ordering of such a cascade will be that of function call semantics. That is to say that if state machine A triggers an event on B and D, and B triggers an event on C, then the runtime evaluation order will be A, B, C, D.
In order to keep your state machine definition free from coupling, you will not want to embed the ID of
some state machine into the code of the handler (though you can certainly do so if you wish). It is
instead recommended that you pass any necessary state machine IDs as event data on begin!
:
(uism/begin! this SM ::sm-id actor-map {:other-machine :machine-id})
and add a handler for the ::uism/started
event that extracts this data and stores it in the
state machine’s local store:
(uism/defstatemachine SM
{...
::uism/states {:initial
{::uism/events
{:uism/started
{::uism/handler
(fn [{::uism/keys [event-data] :as env}]
(let [{:keys [other-machine]} event-data]
(-> env
(uism/store :mid other-machine))))}}}}})
The built-in Fulcro support for aborting network requests requires the use of the actual application. The general
recommendation is to save your app into an atom via Fulcro’s started-callback
.
The state machine load/mutation system supports abort IDs by simply adding an :abort-id
to the options map:
(uism/load env ::session (uism/actor-class env :session) {:abort-id :abort/session-load
::uism/post-event :session-checked})
You can then explicitly cancel such a request in the normal way (via your app atom) inside of your state machine handlers:
...
::uism/handler
(fn [env]
(when @my-app
(fc/abort-request! @my-app :abort/session-load))
env)
This combination of feature leads to very clean UI code.
See state_machine_ws.cljs for the full example.
This relatively small set of primitives gives you quite a bit of power. Here are some things you can do with this system that you might not immediately realize:
-
Associate Multiple Machines with a Control
You might have a state machine that is interested in tracking something like the autocomplete status of a dropdown. Another state machine could be tracking the overall state of the form that the autocomplete is embedded in.
-
Create a Library of Reusable Machines
We’ve mentioned this, but it bears repeating. Common patterns exist all over the place.
Take an autocomplete dropdown. The behavior of waiting for some period of time between
keystrokes before issuing a load, cancelling a load if the user starts typing again,
showing/hiding the list of options and such can all be parameterized. The loads load an
actor with parameters. This means the actual query and results for the load portion
are controlled at begin!
, not from within the state machine. Various other aspects
are also easy to make "tunable" by using the state machine’s local storage:
(defstatemachine dropdown-autocomplete
{::uism/actors #{:dropdown-control ...}
::uism/aliases {:options-visible? [:dropdown-control :ui/show-options?]
...}
::uism/states
{:initial {
::uism/events {::uism/started
{::uism/handler (fn [{::uism/keys [event-data] :as env}]
(uism/store env :params event-data)
...
...
(uism/begin! this dropdown-autocomplete :dropdown-car-make-sm
{:dropdown-control (uism/with-actor-class [:dropdown/id :car-make] Dropdown))}
{:dropdown-key-timeout 200})
My initial experiments lead me towards the opinion that form validation does not generally belong in a state machine. Here are my reasons:
-
Rules around validation are often large and complex. This leads to a lot of states that become hard to follow.
-
The forms-state namespace in Fulcro does a nice job of tracking field state, letting you undo, diff, etc. It is much easier to "follow" validation at the UI layer, where it is also simpler to co-locate validity checks and messages with fields.
So, simple things like a login form might be ok to validate in the state machine, but larger forms should probably localize validation to the form itself.
There is one element that you will often need within the state machine: whether or not the form is currently valid.
Remember:
-
The
env
in the state machine handler includes the current Fulcro state map as the key::uism/state-map
. -
You can use
db→tree
to convert a state map into a tree based on a component’s class. -
You can get an actor’s component class with
uism/actor-class
. -
The form-state support in Fulcro can give you a validity check based on the form props.
You can also pass specific event data when you trigger events, so you can trigger your own state change events
(instead of using uism/set-value!
) that include the current validity of the form.