A small and powerful toolset that brings state machines to re-frame.
EPL-1.0 License
A small and powerful toolset that brings state machines to re-frame.
(ns my-ns
(:require [mainej.re-stated :as state]))
State machines add organizational structure to code, potentially simplifying complex interactions. There has been a small explosion of approaches to integrating clj-statecharts with re-frame.
But many of these approaches are more convoluted than necessary. (See for example, my own earlier attempt clj-statecharts-re-frame.) Even clj-statecharts' own integration with re-frame leaves something to be desired. It leaks memory and can't easily manage several states (ref).
Let's go back to basics to build a truly minimal integration. That is, let's re-state the problem.
First, a quick digression. Let's use this terminology:
[:connecting :handshake]
. It{:_state [:connecting :handshake]}
. It can hold other contextual data which influencesThough each of these things is immutable, we need a way to store and reference how state changes over time. For this, we use re-frame.
A state-map is ... stateful. The word "state" is right there in its name. Where do we store state in a re-frame app? In the app-db, usually. So,
And how do we modify the world in a re-frame app? Events, which lead to effects.
How does a state machine interact with the outside world? Actions.
Believe it or not, that's enough to build any kind of state machine that interacts with re-frame.
To satisfy the first requirementtools for use within event handlerswe want:
(state/initialize-in db [:some :where] fsm {:contextual "data"})
(state/transition-in db [:some :where] fsm :fsm-event)
(re-frame/reg-sub
:some-state
(fn [db _] (get-in db [:some :where state/state])))
For the second requirementevents to dispatch from routers, components and other event handlerswe want:
[::state/initialize [:some :where] fsm {:contextual "data"}]
[::state/transition [:some :where] fsm :fsm-event]
(state/initialize-after [:some :where] fsm {:contextual "data"})
(state/transition-after [:some :where] fsm :fsm-event)
To satisfy the third requirementactions for machines to dispatch eventswe want:
(state/dispatch [:re-frame-event])
(state/dispatch-in [:some :action/saved-in-context])
This is the contents of the re-stated
tool set. Now let's build something.
Suppose we fetch data from an API. We've already set up our request and response like so:
(re-frame/reg-event-fx
:command/fetch-customers
(fn [_ _]
{:http-xhrio {:uri "http://example.com/customers"
:method :get
:response-format (ajax/json-response-format {:keywords? true})
:on-success [:event/customers-fetched]
:on-failure [:event/customers-fetch-failed]}}))
(re-frame/reg-event-db
:event/customers-fetched
(fn [db [_ customers]]
(assoc-in db [:data :customers] customers)))
(re-frame/reg-event-db
:event/customers-fetch-failed
;; no-op
(fn [db [_ _error]] db))
(re-frame/reg-sub
:customers
(fn [db _]
(get-in db [:data :customers])))
Now we'd like to give some feedback while the request is running and when it completes successfully or unsuccessfully.
(def loading-machine
"A machine that keeps track of whether an attempt at loading
succeeded or failed."
(state/machine
{:id :loading
:initial :loading
:states {:loading {:on {:error :error
:success :loaded}}
:error {}
:loaded {}}}))
(re-frame/reg-event-fx
:command/fetch-customers
[(state/initialize-after [:requests :customers] loading-machine)]
;; same as before
,,,)
(re-frame/reg-event-db
:event/customers-fetched
[(state/transition-after [:requests :customers] loading-machine :success)]
;; same as before
,,,)
(re-frame/reg-event-db
:event/customers-fetch-failed
[(state/transition-after [:requests :customers] loading-machine :error)]
;; same as before
,,,)
(re-frame/reg-sub
:customers-request-status
(fn [db _]
(get-in db [:requests :customers state/state])))
(defn customers-component []
(case @(re-frame/subscribe [:customers-request-status])
nil [:button {:type "button"
:on-click #(re-frame/dispatch [:command/fetch-customers])}
"start"]
:loading [:div "loading..."]
:error [:div "oops! something went wrong"]
:loaded [:div (for [customer @(re-frame/subscribe [:customers])]
^{:key (:id customer)}
[customer-component customer])]))
Nice! Now we can give users feedback while they're waiting for the fetch to complete. With a little refactoring it should be easy to add this pattern to all of our requests.
What if we have so many customers with so much data that our customers request is really slow? Perhaps we could load summary data in the first request, then in the background fetch details for each customer.
We'll want a loading/success/failure message for each details request. There are
several ways to do this, but let's continue using the loading-machine
we
created earlier.
(re-frame/reg-event-fx
:event/customers-fetched
[(state/transition-after [:requests :customers] loading-machine :success)]
(fn [{:keys [db]} [_ customers]]
{:db (assoc-in db [:data :customers] customers)
;; start the background requests for customer details
:fx (for [customer customers]
[:dispatch-later {:ms 500 :dispatch [:command/fetch-customer (:id customer)]}])}))
(re-frame/reg-event-fx
:command/fetch-customer
(fn [{:keys [db]} [_ id]]
{:db (state/initialize-in db [:requests :customer id] loading-machine)
:http-xhrio {:uri (str "http://example.com/customers/" id)
:method :get
:response-format (ajax/json-response-format {:keywords? true})
:on-success [:event/customer-fetched id]
:on-failure [:event/customer-fetch-failed id]}}))
(re-frame/reg-event-db
:event/customer-fetched
(fn [db [_ id customer]]
(-> db
(assoc-in [:data :customer-details id] customer)
(state/transition-in [:requests :customer id] loading-machine :success))))
(re-frame/reg-event-db
:event/customer-fetch-failed
(fn [db [_ id _error]]
(state/transition-in db [:requests :customers id] loading-machine :error)))
(re-frame/reg-sub
:customer-request-status
(fn [db [_ id]]
(get-in db [:requests :customer id state/state])))
(re-frame/reg-sub
:customer-details
(fn [db [_ id]]
(get-in db [:data :customer-details id])))
(defn customer-component [{:keys [id]}]
(let [request-status @(re-frame/subscribe [:customer-request-status id])
customer-details @(re-frame/subscribe [:customer-details id])]
(case request-status
nil [:div "summary loaded..."]
:loading [:div "loading details..."]
:error [:div "oops! something went wrong"]
:loaded [:div "hi " (:nickname customer-details) "!"])))
This was a little more complicated because we needed a customer id
to
initialize, transition and read each request. But still, not so bad.
What if we notice our API is a little flaky and want to automatically retry a
few times before giving up? This sounds like something we can model in a state
machine. It'll be similar to our loading-machine
, but with a few more bells
and whistles:
(require '[statecharts.core :as statecharts])
(def retrying-machine
"A machine that tries to recover from errors by retrying. Retries twice before
halting.
Control the event that is retried by setting `:retry-evt` in the state-map."
(state/machine
{:id :retrying
:initial :loading
:states {:loading {:on {:error :error
:success :loaded}}
:error {:initial :retrying
:states {:retrying (letfn [(reset-retries [state-map _]
(assoc state-map :retries 2))
(update-retries [state-map _]
(update state-map :retries dec))
(retries-left? [{:keys [retries]} _]
(pos? retries))]
{:entry (statecharts/assign reset-retries)
:initial :waiting
:states {:waiting {:after [{:delay 1000
:target :loading}]}
:loading {:entry [(statecharts/assign update-retries)
(state/dispatch-in [:retry-evt])]
:on {:error [{:guard retries-left?
:target :waiting}
[:> :error :halted]]
:success [:> :loaded]}}}})
:halted {}}}
:loaded {}}}))
;; The fetch event is split in two. One, to start the state machine
;; and enqueue the initial request.
(re-frame/reg-event-fx
:command/start-fetch-customers
[(state/initialize-after [:requests :customers] retrying-machine
;; on retry, re-fetch the customers
{:retry-evt [:command/fetch-customers]})]
(fn [_ _]
{:fx [[:dispatch [:command/fetch-customers]]]}))
;; And two, to actually place the request. This is the command that is
;; retried.
(re-frame/reg-event-fx
:command/fetch-customers
(fn [_ _]
{:http-xhrio {:uri "http://example.com/customers"
:method :get
:response-format (ajax/json-response-format {:keywords? true})
:on-success [:event/customers-fetched]
:on-failure [:event/customers-fetch-failed]}}))
(re-frame/reg-event-db
:event/customers-fetched
[(state/transition-after [:requests :customers] retrying-machine :success)]
(fn [db [_ customers]]
(assoc-in db [:data :customers] customers)))
(re-frame/reg-event-db
:event/customers-fetch-failed
;; This error will start the next request, if there are any retries left.
[(state/transition-after [:requests :customers] retrying-machine :error)]
(fn [db [_ _error]]
db))
(re-frame/reg-sub
:customers-request-status
(fn [db _]
(statecharts.utils/ensure-vector (get-in db [:requests :customers state/state]))))
(defn customers-component []
(let [[request-status error-status retry-status] @(re-frame/subscribe [:customers-request-status])]
(case request-status
nil [:button {:type "button"
:on-click #(re-frame/dispatch [:command/start-fetch-customers])}
"start"]
:loading [:div "loading..."]
:error [:div "oops! something went wrong"
(case error-status
:retrying (case retry-status
:waiting [:div "please wait"]
:loading [:div "retrying"])
:halted [:div "gave up"])]
:loaded [:div (for [customer @(re-frame/subscribe [:customers])]
^{:key (:id customer)}
[customer-component customer])])))
Need to clear request status after a few seconds? Or poll an API for updates? Or track how a user has interacted with an input field? Or walk a user through a wizard? These are great use cases for state machines too. Find working examples of some of them in the examples directory. What else will you build?
clj-statecharts
lays thexstate
andscxml
.glimt
uses clj-statecharts
toglimt
'sre-stated
was at least asglimt
can be more concise in its domain.See CONTRIBUTING.md.
Copyright 2021 Jacob Maine
Distributed under the Eclipse Public License version 1.0.