re-stated
re-stated copied to clipboard
A small and powerful toolset that brings state machines to re-frame.
A small and powerful toolset that brings state machines to re-frame.
Install
(ns my-ns
(:require [mainej.re-stated :as state]))
Background
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.
Analysis
Terminology
First, a quick digression. Let's use this terminology:
- fsm, or machine: A machine is a specification of all the possible states a process can be in and the transitions between those states.
- state: A state is a keyword or vector like
[:connecting :handshake]. It represents a particular position within the machine: what state we're currently in. - state-map: A state-map is a map that holds a state, e.g.
{:_state [:connecting :handshake]}. It can hold other contextual data which influences how the state is transitioned.
Though 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.
Requirements
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,
- Event handlers need tools to initialize and transition a state-map and store it in the app-db.
And how do we modify the world in a re-frame app? Events, which lead to effects.
- Routers, components and other event handlers need need to be able to dispatch re-frame events that initialize or transition a state-map.
How does a state machine interact with the outside world? Actions.
- State machines need to dispatch re-frame events via actions, i.e. when a state-map enters/exits/transitions between states.
Believe it or not, that's enough to build any kind of state machine that interacts with re-frame.
Implementation
To satisfy the first requirement—tools for use within event handlers—we want:
- A function that, when given a db, db-path, fsm, and some (optional)
contextual data, initializes a state-map and stores it at the db-path. We'll
use this function within event handlers.
(state/initialize-in db [:some :where] fsm {:contextual "data"}) - A function that, when given a db, db-path, fsm and state event transitions
the state-map stored at the db-path. We'll use this function within event
handlers.
(state/transition-in db [:some :where] fsm :fsm-event) - Facilities for reading and subscribing to the current state.
(re-frame/reg-sub :some-state (fn [db _] (get-in db [:some :where state/state])))
For the second requirement—events to dispatch from routers, components and other event handlers—we want:
- Pre-defined events that call the above functions. We'll dispatch these
events from routers, components or other event handlers.
[::state/initialize [:some :where] fsm {:contextual "data"}] [::state/transition [:some :where] fsm :fsm-event] - Event interceptors that augment normal event handlers such that when they're
dispatched, a state-map is also initialized or transitioned. We'll use
these to enhance existing events.
(state/initialize-after [:some :where] fsm {:contextual "data"}) (state/transition-after [:some :where] fsm :fsm-event)
To satisfy the third requirement—actions for machines to dispatch events—we want:
- clj-statecharts actions that dispatch fixed re-frame events. We'll use these
in state machines, in transition/entry/exit actions.
(state/dispatch [:re-frame-event]) - clj-statecharts actions that dispatch re-frame events stored in the
state-map. We'll use these in state machines, in transition/entry/exit
actions. By allowing the state-map to control the event, one state machine
can be used to manage several state-maps.
(state/dispatch-in [:some :action/saved-in-context])
This is the contents of the re-stated tool set. Now let's build something.
Examples
Simple HTTP progress tracking
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.
Many simultaneous 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.
Automatic retries
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?
Inspiration
clj-statechartslays the groundwork for a data-driven model of state machines. It in turn is influenced byxstateandscxml.glimtusesclj-statechartsto manage http requests. Many of the examples in this project mirrorglimt's capabilities, because I wanted to make surere-statedwas at least as powerful. With its narrower focus,glimtcan be more concise in its domain.
Development
See CONTRIBUTING.md.
License
Copyright © 2021 Jacob Maine
Distributed under the Eclipse Public License version 1.0.