Skip to content

Instantly share code, notes, and snippets.

@olivergeorge
Last active Nov 4, 2020
Embed
What would you like to do?

Experiment

Put a clj-statechart somewhere in my mobile app! Learn from the process.

Observations

Certainly better for orchestrating interesting behaviour when compared with simple re-frame handlers. The registration process has lots of corner cases. More of those are covered. The code is by rights more maintainable. I was able to refactor as I expanded it with confidence. So lots of wins there.

Actions tend to be hardcoded to a specific use case. Primarily because they have to dispatch an event which the fsm expects.

By keeping the fsm/machine definition simple edn it's reasonably readable. By rights that should also make it easy enough to generate a diagram from.

The react navigation api is painful. By putting all related navigation in the machine I was able to avoid that some irritating corner cases (modals clobbering one another).

I was able to cater better for "api not responding" (timeouts) and "went offline during server interaction" cases more easily.

The delayed transactions require a scheduler which effectively becomes hidden state. Doesn't really fit with the re-frame model. There's no clean way to split it's "handler" behaviour from it's "fx" behavour without making a mess.

There are points of friction trying to get clj-statecharts to work with re-frame. I made a few changes for convenience (commit has some notes explaining why):

  • This one saves wrapping every action in a wrapper. All my actions return modified state to trigger state change. That is a matter of taste.
  • This one avoids things throwing when an event isn't expected. I think this one deserves consideration.

Wrapping the machine up as a reg-event-fx would have been easier if the fsm/machine could be told to look for state in a path.

I decided to wire the machine up as regular handlers so there wasn't an additional translation layer between the system events and the machine.

I pass re-frame coeffects to the machine as state. That should make it fairly flexible.

Feels similar but better organised than re-frame-async-flow-fx in this case. Not sure it's significantly different for bootstrapping. That's pretty linear.

Each of those cases could have a personalised error and behaviour, for now it's all the same message.

Open questions

  • Is this approach is easy to debug and test? Actions could do less direct work and more dispatch to break things up but that seemed too much.
  • Is this easy to test at the REPL?
  • How likely is it the machine will get stuck in an unexpected state and lock the app?
  • What it would take to do generative testing based on the machine definition? Seems like it should be an effective way to focus on possible combinations. Perhaps guards are declarative enough to be a mechanism for "predicate generators".
(def register-machine
(fsm/machine
{:id ::register-machine
:initial :register-form
:on {::register.reset {:target :reset}}
:states {:reset {:always {:actions [actions/register-form-reset]
:target :register-form}}
:register-form {:entry []
:exit []
:on {::register.region-press
{:target :region-picker}
::register.save-press
[{:guard guards/is-offline?
:actions [actions/say-offline]
:target :failure}
{:guard guards/is-missing-fields?
:actions [actions/say-missing-fields
actions/show-missing-fields]
:target :failure}
{:guard guards/has-errors?
:actions [actions/say-field-errors]
:target :failure}
{:target :register}]}}
:region-picker {:entry [actions/goto-register-region
actions/register-form-backup]
:exit [actions/go-back]
:on {::register-region.cancel-press
{:actions [actions/register-form-restore]
:target :register-form}
::register-region.done-press
{:target :register-form}
::register-region.option-press
{:actions [actions/register-form-set-region]}}}
:register {:initial :submit
:entry [actions/do-register-user
actions/show-twirly]
:exit [actions/hide-twirly]
:on {::register.submit-reject
[{:actions [actions/say-error-registering]
:target :failure}]
::register.submit-response
[{:guard guards/is-incorrect?
:actions [actions/say-incorrect-message]
:target :failure}
{:guard guards/is-interrupted?
:actions [actions/say-error-interrupted]
:target :failure}
{:guard guards/is-unavailable?
:actions [actions/say-error-offline]
:target :failure}
{:guard guards/is-anomaly?
:actions [actions/say-error-registering]
:target :failure}
{:actions [actions/save-user-profile]
:target :success}]}}
:failure {:entry [actions/show-say-modal]
:exit [actions/hide-say-modal]
:on {::register.say-dismiss-press
{:target :register-form}}}
:success {:entry [actions/register-form-reset
actions/show-success]
:exit []
:after [{:delay 2000
:actions [actions/hide-success
actions/goto-mymap]
:target :reset}]}}
:scheduler (delayed/make-scheduler
#(rf/dispatch [::register.send %])
(clock/wall-clock))}))
(defn register-machine-helper
[{:keys [db] :as ctx} e]
(when-let [state-in (get-in db register-machine-path)]
(let [context-in (assoc ctx :fx [])
s0 (merge state-in context-in)
s1 (fsm/transition register-machine s0 e)
state-out (reduce dissoc s1 (keys context-in))]
(js/console.log ::state-out state-out)
{:db (assoc-in (:db s1) register-machine-path state-out)
:fx (:fx s1)})))
(defn register-machine-fx [ctx [type data]] (register-machine-helper ctx {:type type :data data}))
(defn register-machine-send [ctx [_ type data]] (register-machine-helper ctx {:type type :data data}))
(rf/reg-event-fx ::register-region.cancel-press register-machine-fx)
(rf/reg-event-fx ::register-region.done-press register-machine-fx)
(rf/reg-event-fx ::register-region.option-press register-machine-fx)
(rf/reg-event-fx ::register.region-press register-machine-fx)
(rf/reg-event-fx ::register.save-press register-machine-fx)
(rf/reg-event-fx ::register.submit-response register-machine-fx)
(rf/reg-event-fx ::register.submit-reject register-machine-fx)
(rf/reg-event-fx ::register.reset register-machine-fx)
(rf/reg-event-fx ::register.say-dismiss-press register-machine-fx)
(rf/reg-event-fx ::register.send register-machine-send)
(ns app.guards
(:require [interop.anomalies :as anom]
[app.utils :as utils]))
(defn is-incorrect?
[s]
(let [resp (last (:event s))]
(anom/incorrect? resp)))
(defn is-anomaly?
[s]
(let [resp (last (:event s))]
(anom/anomaly? resp)))
(defn is-unavailable?
[s]
(let [resp (last (:event s))]
(anom/unavailable? resp)))
(defn is-interrupted?
[s]
(let [resp (last (:event s))]
(anom/interrupted? resp)))
(defn is-missing-fields?
[s]
(let [form (get-in s [:db :app/register-form])]
(utils/register-form-missing-fields? form)))
(defn has-errors?
[s]
(let [form (get-in s [:db :app/register-form])]
(seq (utils/register-form-errors form))))
(defn is-offline?
[s]
(false? (get-in s [:db :app/online?])))
(ns app.actions
(:require [app.utils :as utils]
[clojure.string :as string]
[app.data :as data]
[interop.anomalies :as anom]))
(defn save-user-token
[s]
(let [token (get-in s [:event 2])]
(assoc-in s [:db :auth/token] token)))
(defn register-form-reset
[s]
(let [region (get-in s [:db :app/current-region])
form (utils/register-form-defaults {:region region})]
(assoc-in s [:db :app/register-form] form)))
(defn register-form-set-region
[s]
(let [[_ value] (:event s)]
(assoc-in s [:db :app/register-form :register-form/region] value)))
(defn register-form-backup
[s]
(let [form (get-in s [:db :app/register-form])]
(assoc-in s [:db :app/register-form-copy] form)))
(defn register-form-restore
[s]
(let [form (get-in s [:db :app/register-form-copy])]
(assoc-in s [:db :app/register-form] form)))
(defn register-region-done-press
[s]
(let [region (get-in s [:db :app/register-form :register-form/region])]
(assoc-in s [:db :app/selected-region] region)))
(defn say-incorrect-message
[s {:keys [data]}]
(let [errors (mapcat val (anom/data data))]
(assoc s :say {:title "Oopsie"
:message (string/join " " errors)})))
(defn say-error-registering
[s]
(assoc s :say {:title "Oopsie"
:message "Unexpected error registering."}))
(defn say-error-interrupted
[s]
(assoc s :say {:title "Oopsie"
:message "Server isn't responding. Try again later."}))
(defn say-error-offline
[s]
(assoc s :say {:title "Oopsie"
:message "We lost the internet connection."}))
(defn register-value-change
[{:keys [event] :as s}]
(let [[_ k v] event]
(case k
"username" (assoc-in s [:db :app/register-form :register-form/username] v)
"password" (assoc-in s [:db :app/register-form :register-form/password] v)
"first_name" (assoc-in s [:db :app/register-form :register-form/first-name] v)
"last_name" (assoc-in s [:db :app/register-form :register-form/last-name] v)
"email" (assoc-in s [:db :app/register-form :register-form/email] v)
"join_mailing_list" (assoc-in s [:db :app/register-form :register-form/join-mailing-list?] v)
"region" (assoc-in s [:db :app/register-form :register-form/region] v)
s)))
(defn go-back
[s]
(->> [:app.fx/nav-dispatch-go-back-action {}]
(update s :fx conj)))
; NOTE: There must be a better way
(defn goto-mymap
[s]
(update s :fx conj
[:app.fx/nav-dispatch-pop-to-top-action {}]
[:app.fx/nav-navigate2 [(str :app.nav/TabStack) {:screen (str :app.nav/MapStack)}]]))
(defn goto-register-region
[s]
(->> [:app.fx/nav-dispatch-navigate-action
{:name (str :app.screens/register-region)}]
(update s :fx conj)))
(defn show-twirly
[s]
(->> [:app.fx/nav-dispatch-navigate-action
{:name (str :app.screens/twirly)}]
(update s :fx conj)))
(defn hide-twirly
[s]
(->> [:app.fx/nav-dispatch-pop]
(update s :fx conj)))
(defn show-success
[s]
(->> [:app.fx/nav-dispatch-navigate-action
{:name (str :app.screens/success)}]
(update s :fx conj)))
(defn hide-success
[s]
(->> [:app.fx/nav-dispatch-pop]
(update s :fx conj)))
(defn show-say-modal
[s]
(->> [:app.fx/nav-dispatch-navigate-action
{:name (str :app.screens/say-modal)
:params (:say s)}]
(update s :fx conj)))
(defn hide-say-modal
[s]
(->> [:app.fx/nav-dispatch-pop]
(update s :fx conj)))
(defn say-missing-fields
[s]
(assoc s :say {:title "Some fields are not set"
:message "Please fill in or set the marked fields"}))
(defn say-offline
[s]
(assoc s :say {:title "Oopsie"
:message "We're offline. Try again later."}))
(defn show-missing-fields
[s]
(assoc-in s [:db :app/register-form :register-form/show-missing-fields] true))
(defn say-field-errors
[s]
(let [form (get-in s [:db :app/register-form])
errors (utils/register-form-errors form)]
(assoc s :say {:title "Oopsie"
:message (string/join " " errors)})))
(defn do-register-user
[s]
(let [ddb (get-in s [:db :ddb/state])
form (get-in s [:db :app/register-form])
data (data/massage (data/register-form-kmap ddb) form)]
(->> [:app.fx/api-register
{:data data
:resolve [:app.events/register.submit-response]
:reject [:app.events/register.submit-reject]}]
(update s :fx conj))))
(defn timeout-p
[p1 ms]
(let [ret (anom/interrupted "Timeout")
p2 (js/Promise. (fn [resolve] (js/setTimeout. #(resolve ret) ms)))]
(js/Promise.race #js [p1 p2])))
(defn unavailable-p
[p1]
(let [ret (anom/unavailable)
p2 (js/Promise.
(fn [resolve]
(let [*unsub (atom nil)
cb #(when-not (:isInternetReachable %)
(when-let [unsub @*unsub] (unsub))
(resolve ret))]
(reset! *unsub (netinfo/add-event-listener cb)))))]
(js/Promise.race #js [p1 p2])))
(defn throw-anoms [r1] (if (anom/anomaly? r1) (throw r1)) r1)
(defn catch-as-fault [err] (if (anom/anomaly? err) err (anom/fault "Unexpected error" err)))
(defn register-fx
[{:keys [data]}]
(let [reg-data (select-keys data [:username :password :region :email :first_name :last_name :join_mailing_list])
auth-data (select-keys data [:username :password])]
(-> (api/register-user {:data reg-data})
(unavailable-p)
(timeout-p 5000)
(.then throw-anoms)
(.then #(api/api-token-auth auth-data))
(timeout-p 5000)
(unavailable-p)
(.then throw-anoms)
(.then #(keychain/set-token {:token (get % "token")}))
(.then throw-anoms)
(.then #(keychain/get-token))
(.catch catch-as-fault))))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment