Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save rgkirch/83428bec5a68f23f76ba9366fdf4c21a to your computer and use it in GitHub Desktop.
Save rgkirch/83428bec5a68f23f76ba9366fdf4c21a to your computer and use it in GitHub Desktop.
current re-frame app state as a fold over events

current re-frame app state as a fold over events

https://day8.github.io/re-frame/EffectfulHandlers/

at any one time, the value in app-db is the result of performing a reduce over the entire collection of events dispatched in the app up until that time. The combining function for this reduce is the set of registered event handlers.

This is attractive. Let’s do it.

High level overview:

We’ll look at how re-frame calls our registered handler function. We care about two things here. We will need to call the handler ourselves; understanding how re-frame is doing it will help with that. We also need to persist the data that re-frame passes to our handler function. If we replay the same events with a different context then we’ll fail to get to the same app state. We need to persist the context as well as the event.

Ok then.

How does re-frame do it?

Where is the combining function? I don’t remember such a function in the public api. Let’s do some digging.

Consider this except from registrar.cljc

;; kinds of handlers
(def kinds #{:event :fx :cofx :sub})

;; This atom contains a register of all handlers.
;; Contains a two layer map, keyed first by `kind` (of handler), and then `id` of handler.
;; Leaf nodes are handlers.
(def kind->id->handler  (atom {})

Nice. We found the set of registered event handler from the quote above.

We’re only interested in event handlers i.e. the kind is :event.

We can use get-handler e.g. (get-handler :event :my-event-id true)

(defn get-handler

  ([kind]
   (get @kind->id->handler kind))

  ([kind id]
   (-> (get @kind->id->handler kind)
       (get id)))

  ([kind id required?]
   (let [handler (get-handler kind id)]
     ;; log a warning if handler is nil
     handler)))

Note:

Passing true for required? tells re-frame to log an error if it doesn’t find the handler.

End note.

Calling get-handler with :event returns all of our registered event handlers. Calling with :event and ::my-event-id takes us straight to our event handler.

(re-frame.registrar/get-handler :event ::my-event-id)
'({:id :coeffects
   :before ...
   :after nil}
  {:id :do-fx
   :before nil
   :after ...}
  {:id :inject-global-interceptors
   :before ...
   :after nil}
  {:id :fx-handler ;; or :db-handler
   :before ...
   :after nil})

The value returned is a list of all of the registered interceptors. All of a handler’s interceptors run forwards and backwards to process an event.

The :before functions are called one after the other to prepare the context for your function. Then your function gets called on the context. Finally, the :after functions are called in turn.

The :coeffects interceptor only has a :before function. Its job is to prepare the context with any coeffects that your handler needs in order to run.

:do-fx only has an :after function and its job is to execute any effects that your handler returns.

:fx-handler (or :db-handler if it was registered with reg-event-db) has your your handler function under the :before key.

To prove that I’m not making this up, here’s an except from interceptor.cljc:

(defn execute
  [event-v interceptors]
  (-> (context event-v interceptors)
      (invoke-interceptors :before)
      change-direction
      (invoke-interceptors :after)))

As an implementation detail, re-frame uses a queue to track the interceptors it has yet to run and a stack to track the interceptors it has already run. The call to change-direction is called after re-frame has emptied the queue into the stack. The call to change-direction then dumps all of the interceptors from the stack into the queue. This reverses them.

You know what, the code is shorter than my explanation.

(defn- change-direction
  [context]
  (-> context
      (dissoc :queue)
      (enqueue (:stack context))))

Here’s a final bit of context before I get to the point.

(defn reg-event-fx
  [id interceptors handler]
  (events/register
   id
   [cofx/inject-db
    fx/do-fx
    std-interceptors/inject-global-interceptors
    interceptors
    (fx-handler->interceptor handler)]))

You see here the global interceptors come before the handler’s interceptors. Any :before we put in the global interceptor list to record the coeffects (including the event) would not include the cofx injected by our handler’s interceptors.

Is this too much information?

It does help us understand one point though. I already said we need to persist the event and the context. Adding an interceptor to the list of global interceptors is not good enough.

If you want to record the input globally for every event handler then you can write some alias functions that edit the handlers interceptor chain.

(defn reg-event-fx
  ([id handler]
   (reg-event-fx id nil handler))
  ([id interceptors handler]
   (re-frame/reg-event-fx id (conj interceptors log-cofx) handler)))

(defn reg-event-db
  ([id handler]
   (reg-event-db id nil handler))
  ([id interceptors handler]
   (re-frame/reg-event-db id (conj interceptors log-cofx) handler)))

Note:

My function names are very original. You don’t have to be this creative with yours.

End note.

However, you probably don’t want to make this change for every handler. Initially, I thought that this is what I wanted to do because I was trying to create a global undo function. I configured Ctrl+Z to pop the most recent event from the event store and re-compute the previous app state. Hilariously, this just restored the app to the state right after I pressed the Ctrl key. Not what I was expecting but it’s exactly what I asked the computer to do. Ha!

I need to refine the requirements for my use case. What events you record is up to you. Just remember that you put the interceptor at the end of the chain if you want to capture the coeffects from the previous interceptors.

Here is a look at my log-cofx:

(def event-store (atom (list)))

(def log-cofx
  (re-frame.core/->interceptor
   :id      :log-cofx
   :before  (fn [ctx]
              (do
                (swap! event-store
                       conj
                       (dissoc (re-frame/get-coeffect ctx) :db))
                ctx))))

And a look at how I use it:

(re-frame/reg-event-fx
 ::toggle-modal
 log-cofx
 (fn [{:keys [db]} _]
   (toggle-modal db)))

Perfect.

That covers persisting the data and a bit about how re-frame stores and calls our handler. Let’s finish by looking at how we can get and call our handler.

(defn get-handler
  [event-id]
  (-> (filter (fn [{id :id}] (#{:fx-handler :db-handler} id))
              (re-frame.registrar/get-handler :event event-id))
      first
      :before))

(defn fold-event-context
  [db ctx]
  (let [event-v (-> ctx :coeffects :event)
        handler (-> event-v first get-handler)
        {{db :db} :effects} ((or handler identity) (assoc-in ctx [:coeffects :db] db) event-v)]
    db))

(defn fold-events
  [initial-db contexts]
  (reduce fold-event-context initial-db contexts))

There we go.

That’s it.

That’s the big payoff.

Let’s wrap this up by filling in our understanding of what’s in the context and what gets passed to the handler.

Here’s an except from the docs on that gives an idea of what the context looks like.

https://github.com/day8/re-frame/blob/master/docs/Interceptors.md#what-is-context

{:coeffects {:event [:a-query-id :some-param]
             :db    <original contents of app-db>}
 :effects   {:db    <new value for app-db>
             :dispatch  [:an-event-id :param1]}
 :queue     <a collection of further interceptors>
 :stack     <a collection of interceptors already walked>}

You can also find it in the docstring for re-frame.interceptor/execute. https://github.com/day8/re-frame/blob/master/src/re_frame/interceptor.cljc

I’ll also note that what gets passed to the handler is a little different depending on if it was registered through fx or db.

(defn reg-event-db
  [id interceptors handler]
  (events/register id [... (db-handler->interceptor handler)]))
(defn reg-event-fx
  [id interceptors handler]
  (events/register id [... (fx-handler->interceptor handler)]))

The source for these two functions looks much the same except that they use a different function to wrap the handler.

The wrapper in both cases accepts a context.

(defn db-handler->interceptor
  [handler-fn]
  (->interceptor
   :id :db-handler
   :before (fn db-handler-before
             [context]
             (let [new-context
                   (let [{:keys [db event]} (get-coeffect context)]
                     (->> (handler-fn db event)
                          (assoc-effect context :db)))]
               new-context))))
(defn fx-handler->interceptor
  [handler-fn]
  (->interceptor
   :id :fx-handler
   :before (fn fx-handler-before
             [context]
             (let [new-context
                   (let [{:keys [event] :as coeffects} (get-coeffect context)]
                     (->> (handler-fn coeffects event)
                          (assoc context :effects)))]
               new-context))))

The only difference between these two is that in the fx case the handler is called with the coeffects while in the db case it’s called with the db.

The takeaway from this is to know that the handler we get out of re-frame (via get-handler) will take the context. It doesn’t matter weather we registered it through reg-event-db or reg-event-fx.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment