Sept. 27, 2023, 12:29 a.m.

Doing your own IO stunts!

Curiosities by Tensegritics

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.)

Baptiste does his own IO stunts!

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:

  • Using switchMap we can convert all of our previous Streams 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.
  • Using this strategy we package all firebase 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.

You just read issue #3 of Curiosities by Tensegritics. You can also browse the full archives of this newsletter.

Share on Twitter Share on LinkedIn Share on Hacker News Share on Reddit Share on Mastodon
This email brought to you by Buttondown, the easiest way to start and grow your newsletter.