In this issue, Baptiste shares some experience on how to deal with "cascading IO". The promised FFI stories will be for a next issue.
He also just published a short on storing user preferences in your app.
(cgrand's note: I'm responsible for the lame title pun which only makes sense in French where "stunt" and "cascade" are the same word.)
A common need we face when programming UI applications is handling cascades of events coming from different datasources.
Login is a prime example: you first need to authenticate the user before retrieving her data. When logging out you should immediately stop querying for her data.
(For these examples we used the context of a Firebase-powered personal finance app of our own we are working on.)
A naive approach could be:
(f/widget
;; we listen to Firebase Auth `Stream`
;; we either have an authenticated `User` or nil (when nobody is authenticated)
:watch [^fb-auth/User? u fb-auth-stream]
(if-some [{:flds [uid]} u]
(f/widget
;; if we have a `User` we can listen to a `Stream` of `DatabaseEvent`
;; where user settings are stored at path `/users/{uid}/budgets`
:watch [^fb-database/DatabaseEvent de (.-onValue (u/dbref "users" uid "budgets"))]
;; unwrap the first value if there is some
(if-some [budget-id (some-> db-event->val ffirst)]
(f/widget
;; once we have a `budget-id` from the last `Stream`
;; we listen to its address in Firebase Realtime Database
;; this is where all the data for a given budget are stored
:watch [^fb-database/DatabaseEvent de (.-onValue (u/budget-ref budget-id))]
:let [data (some-> de db-event->val u/firebase-decode)]
(my-budget-widget uid budget-id data))
(budget-creation-widget uid)))
login-page-widget))
However this naive approach exposes too many user-visible states: because we are tying different datasources events to the UI (3 :watch
directives) we are creating unnecessaries intermediates states, which create glitches: the user would briefly see the budget-creation-widget
while her budget is is being determined and then see her budget dashboard with no data while data is loading!
A better solution is to bundle IO processing in a way which reduces intermediate states and keeps app-state
as the only source of truth.
In our opinion, it's always worth reducing UI state space, it makes for easier reasoning. A "razor" we use to decide when something should be allowed in the UI state space is "does it provide valuable or actionable information to the user?".
That's why we merged all streams into a single one, making all intermediate states disappear: a user transitions from logged out to fully logged in without visible glitchy and useless intermediate state.
The switchMap method in the stream_transform package (by Dart team) turned out to be what we needed:
Stream
s into one Stream
. It lets us build Stream
conditionally depending on precedent Stream
value. It handles Stream
disposal and can force Stream
creation when a parent Stream
emits a new event.IO
into one blackbox Stream
But first, we are Clojurists and we need to make switchMap
more palatable thanks to a sweet macro:
(defmacro switch-let
[bindings & body]
(if-some [[binding stream & bindings] (seq bindings)]
`(-> ~stream stream st/Switch (.switchMap (fn ^Stream [~binding] (switch-let ~bindings ~@body))))
`(do ~@body)))
Using this macro we can rewrite our naive code:
;; will creates a `Stream` of clj maps.
(defn io-process
[fb-auth]
(switch-let [^fb-auth/User? u fb-auth]
(when-some [{uid .-uid} u]
(switch-let [^fb-database/DatabaseEvent de (.-onValue (u/dbref "users" uid "budgets"))]
(if-some [budget-id (some-> de db-event->val ffirst)]
(stream (map #(hash-map
:uid uid
:budget-id budget-id
:data (some-> % db-event->val u/firebase-decode)))
(.-onValue (u/budget-ref budget-id)))
(Stream.value {:uid uid})))))))
(f/widget
;; we listen to our synthetic stream
:watch [{:keys [uid budget-id data]} (io-process (fb-auth/FirebaseAuth.instance.authStateChanges))]
(cond
(not uid) login-page-widget
(not budget-id) (budget-creation-widget uid)
:else (my-budget-widget uid budget-id data)))
The code has been greatly simplified to make for an easier comparison between both takes. (Our actual code has error handling reporting in Crashlytics and uses a :bg-watcher
instead of a :watch
and alters the app-state. The UI then reacts to the app-state modification.)
We hope that you'll take the habit to merge related IO in fat "processes" which only publish states meaningful for the user.