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
.