This is important because the fact that event handlers are pure functions brings great advantages:
- Local reasoning, which decreases the cognitive load required to understand the code.
- Easier testing, because pure functions are much easier to test.
- Events replay-ability, you can imagine a re-frame application as a reduce (left fold) that proceeds step by step. Following this mental model, at any point in time, the value of the application state would be the result of performing a reduce over the entire collection of events dispatched in the application up until that time, being the combining function for this reduce the set of registered event handlers.
However, to build a program that does anything useful, it's inevitable to have some side-effects and/or side-causes. So, there would be cases in which event handlers won't be pure functions.
In this post, we'll focus on side-causes.
"Side-causes are data that a function, when called, needs but aren't in its argument list. They are hidden or implicit inputs."Let's see an example:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(ns visual-spelling.play.answer.handlers | |
(:require | |
[re-frame.core :as re-frame] | |
[visual-spelling.db :as db] | |
[visual-spelling.words-in-use :as words-in-use])) | |
(re-frame/reg-event-db | |
:word-ready-to-check | |
(fn [db _] | |
(update | |
(db/save-user-answer | |
(js/Date.now) ;; <- side-cause!! | |
db) | |
:words-in-use | |
words-in-use/check-current-word))) |
To be able to test this event handler we'll have to somehow stub the function that produces the side-cause. In ClojureScript, there are many ways to do this (using with-redefs, with-bindings, a test doubles library like CircleCI's bond, injecting and stubbing the dependency, etc.).
In this example, we chose to make the dependency that the handler has on a function that returns the current timestamp explicit and inject it into the event handler which becomes a higher order function.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(ns visual-spelling.play.answer.handlers | |
(:require | |
[visual-spelling.db :as db] | |
[visual-spelling.words-in-use :as words-in-use])) | |
(defn word-ready-to-check-handler [time-fn db _] | |
(update (db/save-user-answer (time-fn) db) | |
:words-in-use | |
words-in-use/check-current-word)) |
And this would be how before registering that event handler with reg-event-db, we perform a partial application to inject JavaScript's Date.now function into it:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(ns visual-spelling.play.answer.register-handlers | |
(:require | |
[re-frame.core :as re-frame] | |
[visual-spelling.play.answer.handlers :as handlers])) | |
(re-frame/reg-event-db | |
:word-ready-to-check | |
(partial handlers/word-ready-to-check-handler js/Date.now)) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(deftest test-word-ready-to-check-handler | |
(testing "when answer is correct, saves current word and updates status" | |
(let [time-fn (stub-time-fn :any-timestamp) | |
right-answered-word (make-word | |
:correct-word "o" | |
:status :all-gaps-answered | |
:parts (parts {:correct-answer "o" | |
:options ["a" "o" "e" "u"] | |
:user-answer "o"})) | |
db (build-db :current-word right-answered-word) | |
resulting-db (answer-handlers/word-ready-to-check-handler | |
time-fn db :no-event)] | |
(check-status | |
resulting-db :all-right-answers) | |
(check-results-contains | |
resulting-db (results/correct "o" :any-timestamp))))) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(ns visual-spelling.tests-helpers) | |
(defn stub-time-fn [& timestamps] | |
(let [a-ts (atom timestamps)] | |
(fn [] | |
(let [ts (first @a-ts)] | |
(swap! a-ts rest) | |
ts)))) |
The bottom line problem is that the event handler is not a pure function. This makes us lose not only the easiness of testing, as we've seen, but also the rest of advantages cited before: local reasoning and events replay-ability. It would be great to have a way to keep event handlers pure, in the presence of side-effects and/or side-causes.
Since re-frame's 0.8.0 (2016.08.19) release, this problem has been solved by introducing the concept of effects and coeffects. Effects represent what your program does to the world (side-effects) while coeffects track what your program requires from the world (side-causes). Now we can write effectful event handlers that keep being pure functions.
Let's see how to use coeffects in the previous event handler example. As we said, coeffects track side-causes, (see for a more formal definition Coeffects The next big programming challenge).
At the beginning, we had this impure event handler:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(ns visual-spelling.play.answer.handlers | |
(:require | |
[re-frame.core :as re-frame] | |
[visual-spelling.db :as db] | |
[visual-spelling.words-in-use :as words-in-use])) | |
(re-frame/reg-event-db | |
:word-ready-to-check | |
(fn [db _] | |
(update | |
(db/save-user-answer | |
(js/Date.now) ;; <- side-cause!! | |
db) | |
:words-in-use | |
words-in-use/check-current-word))) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(ns visual-spelling.play.answer.handlers | |
(:require | |
[visual-spelling.db :as db] | |
[visual-spelling.words-in-use :as words-in-use])) | |
(defn word-ready-to-check-handler [time-fn db _] | |
(update (db/save-user-answer (time-fn) db) | |
:words-in-use | |
words-in-use/check-current-word)) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(ns visual-spelling.play.answer.handlers | |
(:require | |
[visual-spelling.db :as db] | |
[visual-spelling.words-in-use :as words-in-use])) | |
(defn word-ready-to-check-handler [cofx _] | |
(let [db (update (db/save-user-answer (:timestamp cofx) (:db cofx)) | |
:words-in-use | |
words-in-use/check-current-word)] | |
{:db db})) |
The map of coeffects, cofx, is the complete set of inputs required by the event handler to perform its computation. Notice how the application state is just another coeffect.
This is the same event handler but using destructuring (which is how I usually write them):
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(ns visual-spelling.play.answer.handlers | |
(:require | |
[visual-spelling.db :as db] | |
[visual-spelling.words-in-use :as words-in-use])) | |
(defn word-ready-to-check-handler [{:keys [db timestamp]} _] | |
(let [db (update (db/save-user-answer timestamp db) | |
:words-in-use | |
words-in-use/check-current-word)] | |
{:db db})) |
We need to do two things previously:
First, we register the event handler using re-frame's reg-event-fx instead of reg-event-db.
When you use reg-event-db to associate an event id with the function that handles it, its event handler, that event handler gets as its first argument, the application state, db.
While event handlers registered via reg-event-fx also get two arguments, the first argument is a map of coeffects, cofx, instead of the application state. The application state is still passed in the cofx map as a coeffect associated to the :db key, it's just another coeffect. This is how the previous pure event handler gets registered:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(ns visual-spelling.play.answer.register-handlers | |
(:require | |
[re-frame.core :as re-frame] | |
[visual-spelling.play.answer.handlers :as handlers])) | |
(re-frame/reg-event-fx | |
:word-ready-to-check | |
[(re-frame/inject-cofx :timestamp)] ;; <- interceptor injects coeffect | |
handlers/word-ready-to-check-handler) |
In this example, we are passing an interceptor created using re-frame's inject-cofx function which returns an interceptor that will load a key/value pair (coeffect id/coeffect value) into the coeffects map just before the event handler is executed.
Second, we factor out the coeffect handler, and then register it using re-frame's reg-cofx. This function associates a coeffect id with the function that injects the corresponding key/value pair into the coeffects map. This function is known as the coeffect handler. For this example, we have:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(ns visual-spelling.play.answer.register-handlers | |
(:require | |
[re-frame.core :as re-frame])) | |
(re-frame/reg-cofx | |
:timestamp | |
(fn [cofx _] | |
(assoc cofx :timestamp (js/Date.now)))) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(deftest test-word-ready-to-check-handler | |
(testing "when answer is correct, saves current word and updates status" | |
(let [right-answered-word (make-word | |
:correct-word "o" | |
:status :all-gaps-answered | |
:parts (parts {:correct-answer "o" | |
:options ["a" "o" "e" "u"] | |
:user-answer "o"})) | |
cofx {:db (build-db :current-word right-answered-word) | |
:timestamp :any-timestamp} | |
resulting-fx (answer-handlers/word-ready-to-check-handler | |
cofx :no-event)] | |
(check-status | |
resulting-fx :all-right-answers) | |
(check-results-contains | |
resulting-fx (results/correct "o" :any-timestamp))))) |
More importantly, we've also regained the advantages of local reasoning and events replay-ability that comes with having pure event handlers.
In future posts, we'll see how we can do something similar using effects to keep events handlers pure in the presence of side-effects.