Awesome
Statecharts for re-frame
A richer re-frame wrapper for clj-statecharts
Rationale
While the default re-frame integration is perfectly fine for many cases, there are also several other ways to do it using re-frame. This integration tries to minimize boilerplate in the lower level API, while providing several higher level utilities to make it easier to work with FSMs.
Examples
The examples below can be seen in action here
Closed mode
Let's start by looking at form validation as an example. Validation is simple enough, but you might want to make an effort to provide nice UX. Telling the user she did something wrong before she even had a chance to do anything is not very nice. We can use an FSM to solve that by tracking the possible state the user can be in:
(require '[re-statecharts.core :as rs])
(def validation-fsm
{:id :validation
:initial ::clean
:states {::clean {:on {::edit-started ::editing}}
::editing {:on {::edit-ended ::dirty}}
::dirty {:on {::edit-started ::editing}}}})
The above code is an FSM as defined by clj-statecharts. It doesn't look like much, but tracking the same logic with ad hoc state flags is often messier than you think. And we are probably covering some edge cases that you didn't think about.
With the FSM in place, we can create a UI:
(defn form []
(rs/with-fsm [state validation-fsm]
(r/with-let [text (r/atom "")
update-text #(reset! text (-> % .-target .-value))]
[:div
(rs/match-state @state
::editing [:div "User is editing..."]
::clean [:div "No changes made yet"]
::dirty [:div
"Form has been modified and is "
(if (seq @text)
"valid"
[:span {:style {:color :red}} "invalid"])]
nil [:div])
[:input {:type :text
:on-change #(update-text %)
:on-focus #(f/dispatch [::rs/transition :validation ::edit-started])
:on-blur #(f/dispatch [::rs/transition :validation ::edit-ended])}]
[:button {:on-click #(f/dispatch [::rs/restart :validation])} "Reset input FSM"]])))
There' a lot going on here, so I'll walk through them in sequence
-
Since the FSM is only of interest to us within the scope of this component, we use the
with-fsm
macro, that implicitly starts the FSM when component mounts and stops it on unmount. It also automagically subscribes to the FSM state through thestate
binding. -
match-state
is useful for simply declaring a view for each possible state of the FSM. -
We indicate validation error if and only if the user has finished editing and the value is invalid.
-
In the event handlers, we see how to trigger state transitions.
Open mode
The above example requires using the event ::rs/transition
to transition the machine. Alternatively you can use "open"
mode. This mode will consider every incoming re-frame event as a potential transition for our FSM.
The open mode is more flexible, but also less efficient since it will run the transition
function of clj-statecharts
a lot more often. For most apps this is probably fine, but if you are trying to save CPU cycles you might want to
consider your options carefully.
For this mode to work, the FSM id needs to be the second element of the event vector.
Here's an example:
(def open-validation-fsm
^{::rs/open? true}
{:id :validation-open
:initial ::clean
:states {::clean {:on {::edit-started ::editing}}
::editing {:on {::edit-ended ::dirty}}
::dirty {:on {::edit-started ::editing}}}})
Notice the metadata that sets the :open?
option. Now we can use this FSM in a UI using only plain re-frame events:
;; These particular events are just no-ops. But you can of course have your events do interesting things, while still
;; triggering FSM transitions.
(f/reg-event-fx ::edit-started (constantly nil))
(f/reg-event-fx ::edit-ended (constantly nil))
(defn form []
(fsm/with-fsm [state open-validation-fsm]
(r/with-let [text (r/atom "")
update-text #(reset! text (-> % .-target .-value))]
[:div
[:input {:type :text
:on-change #(update-text %)
:on-focus #(f/dispatch [::edit-started :validation-open])
:on-blur #(f/dispatch [::edit-ended :validation-open])}]])))
Lower level API
The FSM above can also be started and stopped like this:
(f/dispatch [::rs/start validation-fsm])
(f/dispatch [::rs/stop (:id validation-fsm)])
(f/dispatch [::rs/restart (:id validation-fsm)])
Options
If you need to provide clj-statecharts options, then you can add them as metadata:
(def validation-fsm
^{:transition-opts {:ignore-unknown-event? true}}
{:id :validation
....})
Handling state
This library wants to be open to different shapes of dbs. Therefore, a multimethod exists for reading and writing FSM
state to DB. The default implementation assumes a regular map, and stores the state under the ::rs/fsm-state
key.
If you have some other preference, for example a normalized DB implementation that identifies things by UUID, you could do the following:
(defmethod rs/get-state UUID
[db id]
(db/pull db [:by-uuid id]))
(defmethod rs/set-state UUID
[db id new-state]
(db/transact! db (assoc new-state :uuid id)))
Since the built-in implementation is :default
, any implementation that you provide that is more specific will take
presedence. So if you prefer a path for example, just implement the multimethod for the vector type.
Implementation details
clj-statecharts' built-in re-frame integration creates a separate init and transition handler per FSM.
This integration goes in a different direction. There is only one event for init and one (optional) for transition.
The FSM instance is maintained within the scope of a re-frame global interceptor. There is one interceptor per FSM, and the interceptor is cleared when FSM is stopped.