I scratched an itch and tried to rewrite the re-frame event loop. Here's what I came up with...
Two novel properties:
-
It’s designed to encourage composable logic. Logic handlers take state and return state. This allows :db to flow through handlers. For side effects you can hook up an :fx key which is a list of effects to process.
-
It’s compatible with asynchronous data source inputs. The new world order of asynchronous apis is turning our “pure logic” handlers into callback hell. This should allow SQLite queries as inputs to handlers on React Native apps. Only good for reference data (not “state”) and APIs which won’t block or be slow to respond.
There are examples in the repo.
NOTE: I'm confident composable logic is a good idea. Asynchronous inputs, on the other hand, are unproven.
The event loop can be customised by registering handlers.
:preloadfns fetch data from asynchronous sources (e.g. they return a promise):inputfns provide state to our system (e.g. get current state of atom):logicfns transform state (e.g. set loading? flag):transitionfns process the state change (e.g. update atom, process effects):effectfns undertake some kind of side-effect (e.g. GET request)
Let's add an event loop to a reagent app.
Our state will live in a reagent atom.
(def app-db (r/atom {}))We will start by registering a :db handler with :input and :transition fns. These work to sample the app-db value before processing an event, then update it afterwards.
(reg {:id :db
:input (fn [] @app-db)
:transition (fn [_ db] (reset! app-db db)}))Next, we're registering a :fx handler with a :transition fn which will process any side-effects required by our logic.
(reg {:id :fx :transition do-effects})The most common side-effect is dispatching events. Let's register :dispatch handler with an :effect fn for that.
(reg {:id :dispatch :effect dispatch})Our logic will often want to reference the data passed with the event. We'll add an :event handler with an :input fn for that.
(reg {:id :event :input (fn [ctx] (:event ctx))})Finally, we'll make the standard inputs available to all :logic handlers. Individual handlers can add more.
(cfg :std-ins {:db [:db] :fx [:fx] :event [:event]})Okay, the plumbing is in place.
Now let's write some business logic. They take state and transform it.
The most common tranformations our logic will make are
- updating the :db to change the application state e.g.
(assoc-in s [:db :loading?] true) - updating the :fx to trigger some effect e.g.
(update s :fx conj {:dispatch [:some-event]})
(defn log-state [s] (println :log-state s) s)
(defn set-loading [s] (assoc-in s [:db :loading?] true))
(defn clear-loading [s] (update s :db dissoc :loading?))
(defn get-data [s] (update s :fx conj {::GET {:url "/endpoint/data" :cb #(dispatch [:app/get-resp %])}}))
(defn get-resp [s] (assoc-in s [:db :data] (get-in s [:event 1])))
(defn GET [{:keys [cb]}] (js/setTimeout #(cb {:results [1 2 3]}) 1000))Now let's register our event handlers with :logic fns.
(reg {:id :app/bootstrap :logic (comp set-loading get-data log-state)})
(reg {:id :app/get-resp :logic (comp get-resp clear-loading log-state)})We also need to register our :app/GET handler with :effect fn.
(reg {:id :app/GET :effect GET})Perhaps it'll be useful to watch the app-db and observe what data is changing.
(defn diff-report [[a b]] (println "app-db change:\n only-before" a "\n only-after" b))
(add-watch app-db ::app-db (fn [k _ o n] (diff-report (clojure.data/diff o n))))Did it work? Let's dispatch an event and see.
(dispatch [:app/bootstrap]))Each step in the event loop can log. That has the potential to be verbose but you can control which bits you watch.
Here's a logger which logs do-event and do-transition to the console. It includes the full context at both steps as metadata.
(defn form-logger [ctx k & args]
(when (#{:condense.event-loop/do-event :condense.event-loop/do-transition} k)
(js/console.log k (with-meta (:event ctx) ctx))))
(cfg :log form-logger)The event data type used when dispatching is a choice. The only requirement is that a multimethod style :dispatch-fn is provided to get the handler id.
By default, :dispatch-fn is first which allows for re-frame style vector style (e.g. (dispatch [:event-id arg])).
To dispatch maps (e.g. (dispatch {:id :event-id :data arg})) set :dispatch-fn to something like :id.