-
-
Notifications
You must be signed in to change notification settings - Fork 714
Effectful Event Handlers
This page describes features which may appear in the upcoming v0.8.0 release of re-frame. Comment and discussion on this feature is happening here. Please feel free to correct any typos and mistakes. Thanks.
This tutorial shows you how to implement pure event handlers that side-effect. Yes, I'm serious.
In the process, via the explanations provided,
you'll also get insight into how you might use an alternative to app-db
, like
perhaps a DataScript database.
Not all event handlers are pure. The great majority are but there'll always be some handlers which need to side-effect.
First, the good...
Here we register a pure event handler:
(register-handler
:my-event
(fn [db [_ a]] ;; <--- lovely and pure
(assoc db :flag true)))
The event handler registered here is passed values, returns a value. No side-effects.
Now, to the more troublesome...
This handler is not pure:
(register-handler
:my-event
(fn [db [_ a]]
(dispatch [:do-something-else 3]) ;; oops, side-effect
(assoc db :flag true)))
It is passed values, returns a value. But... it
has a side-effect. That dispatch
queues up another event to be processed. It changes the world.
And, this is not pure either:
(register-handler
:my-event
(fn [db [_ a]]
(GET "http://json.my-endpoint.com/blah" ;; dirty great big side-effect
{:handler #(dispatch [:process-response %1])
:error-handler #(dispatch [:bad-response %1])})
(assoc db :flag true)))
Sure, this approach works. But that dirty great big side-effect doesn't come for free.
Both of those impure event handlers will work. So what's the problem?
In theory, a re-frame application proceeds step by step, like a reduce:
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.
Such a collection of events is replay-able and that's a dream for debugging. But only
when all the handlers are pure. Handlers with side-effects (like that HTTP GET, or the dispatch
) pollute the
replay process, inserting extra events, etc, which ruins the process.
And, it is hard to test these impure handlers. Did the handler GET the right URL? Did it dispatch the right event? Now we have to start mocking things out. Everything gets harder.
So, if you use impure handlers, you end up with a small collection of irritating paper cuts. It isn't a disaster, but we'd like to do better.
Side-effecting event handlers are inevitable. Event handlers implement the control logic in your re-frame app, and that means dealing with the mutative world of servers, databases, windows.location, cookies, etc.
There's just not a lot of getting around that.
Sounds a dire diagnosis? No solution?
Well, it turns out there is a solution. There is a way to create pure event handlers which cause effects, without actually doing the side-effects.
Above, I claimed that this fn
event handler was pure:
(register-handler
:my-event
(fn [db _]
(assoc db :flag true)))
Takes a db
value, returns a db
value. No side-effects. Pure!
All true, but ... this purity is only possible because OTHER parts of re-frame are doing the necessary side-effecting.
Wait on. What "necessary side-effecting"?
Well, app-db
is a ratom, right? It contains the application state and it must be reset!
to the value
returned by the handler. Here's the sequence carried out by
re-frame for each event which is handled:
- extract the value (a map) from
app-db
(a ratom) - call the event handler with this
db
value as the first argument -
reset!
to returned value back intoapp-db
So, you only get to live in your nice pure, ascetic functional programmer world because re-frame is looking after the necessary side-effect on app-db
.
Turns out it's the same pattern with Reagent/React.
You write a nice pure component:
(defn say-hi
[name]
[:div "Hello " name])
and Reagent/React mutates the DOM for you. The framework is looking after the "necessary side-effects".
Pause and look back at say-hi
. I'd like you to view it through the following prism: it is a pure
function which returns a description of the side-effects required. It says: add a div element
to the DOM.
Notice that the description is declarative. We don't tell React how to do it.
Notice also that it is data. Hiccup is just vectors and maps.
This is a big, important concept. While we can't get away from certain side-effects, we can program using pure functions which describe side-effects, declaratively, in data and let the backing framework look after the "doing" of them. Efficiently. Discreetly.
Let's use this pattern to solve the side-effecting handler problem.
From here, two steps:
- Work out how event handlers can declaratively describe side-effects, in data.
- Work out how re-frame can do the "necessary side-effecting". Efficiently and discreetly.
So, how would it look if event handlers returned side-effects, declaratively, in data?
Here is an impure handler:
(register-handler
:my-event
(fn [db [_ a]]
(dispatch [:do-something-else 3]) ;; Eeek, side-effect
(assoc db :flag true)))
Here it is re-written so it is pure:
(register-handler
:my-event
(fn [db [_ a]]
{:db (assoc db :flag true) ;; side-effect we want on db
:dispatch [:do-something-else 3]})) ;; side-effect from dispatching
The handler is returning a data structure which describes two side-effects:
- update app-db (application state) with this new
db
value - dispatch an event
Above, the impure handler caused a dispatch
side-effect, while the pure handler described
a dispatch
side-effect.
The impure way:
(register-handler
:my-event
(fn [db [_ a]]
(GET "http://json.my-endpoint.com/blah" ;; dirty great big side-effect
{:handler #(dispatch [:process-response %1])
:error-handler #(dispatch [:bad-response %1])})
(assoc db :flag true)))
the pure, descriptive way:
(register-handler
:my-event
(fn [db [_ a]]
{:http {:method :get
:url "http://json.my-endpoint.com/blah"
:on-success [:process-blah-response]
:on-fail [:failed-blah]}
:db (assoc db :flag true)}))
Again, the old way causes a side-effect (Booo!) and the new way describes, declaratively, in data, the side-effects required (Yaaa!).
Time for the next step.
So far we've been experimenting with side-effects
but that's only one half of the picture. There
are actually two concepts at play here:
- Effects - what your event handler does to the world (aka side-effects)
- Coeffects - what your event handler requires from the world (aka side-causes)
So now we'll talk about the 2nd part: coeffects
Above, I proposed this event handler:
(register-handler
:my-event
(fn [db [_ a]]
{:db (assoc db :flag true) ;; side-effect we want on db
:dispatch [:do-something-else 3]})) ;; side-effect from dispatching
I'd now like to change the name of the first handler argument from db
to world
,
and pass in this structure {:db db}
. Ie. the argument is now a map, and the
key :db
has the value in app-db
.
So, we rewrite our handler like this:
(register-handler
:my-event
(fn [world [_ a]] ;; world, not db as parameter
{:db (assoc (:db world) :flag true)
:dispatch [:do-something-else 3]}))
or perhaps like this (shrug):
(register-handler
:my-event
(fn [world [_ a]] ;; world, not db as parameter
(-> world ;; world is {:db db}
(assoc-in [:db :flag] true)
(assoc-in [:dispatch] [:do-something-else 3]))))
So the 1st parameter to the event handler, world
, is the "state of the world" or
"computational context" in which the handler is to run. It is the coeffect.
This rewrite means that world
can now be more than db
. It is now whatever "computation context"
the handler needs to perform its computation. Most of the time,
that's just what's in app-db, but "other aspects of the computation context" are possible.
For example, what if a handler needed to know the current datetime? Or, it needed a random number. Or, what if some state was stored in a DataScript database?
All possible, but you must know how "new worlds" are created ...
A "new world" is created each and every time an event handler is run. A brand spanking new world. And it is the event handler's middleware stack which makes that happen.
This can be a surprise to even experienced re-frame users. After all, look at this event handler ... there's no apparent middleware:
(register-handler
:my-event ;; no apparent middleware !!!!!
(fn [db _]
(assoc db :flag true)))
But, don't be fooled. re-frame.core/register-handler
exists for only one
reason: to install the pure
middleware on all your handlers in the
right place within the stack. Here's proof
It is pure
which does the important work. First, it takes the value out of
app-db
, makes it "the world" by providing
it to the event handler as the first argument and then, after the handler returns, it is again
pure
which uses swap!
to mutate app-db
.
So,
pure
is that bit of re-frame which does BOTH effects and coeffects. It obtains and providesdb
to an event handler and, afterwards, it does the "necessary side-effecting" to put data back intoapp-db
.
While pure
is central, re-frame's other middleware can also contribute to
effects and coeffects. For example, undoable
, snapshots the value in app-db
. And after
is used in the todomvc
example to write out to LocalStore.
So, within re-frame, effects and coeffects are done with middleware. We're now going to up the ante a bit.
So, how are we going?
Earlier, I presented a two step plan:
- Work out how event handlers can declaratively describe side-effects, in data.
- Work out how re-frame can action these side-effecting descriptions, efficiently and discreetly
We have a solution for 1.
And, regarding point 2, we now know that middleware is the answer, somehow.
Let's give this new middleware a name: fx
- (effects, geddit?)
Below I provide a specific solution for fx
but before we get there:
- remember, you can write your own middleware. It isn't hard.
-
fx
is definitely not the only way to do this.
fx
is going to be an alternative for pure
.
You will continue to use pure
for regular
handlers whose effects and coeffects are limited to app-db
.
But you will use fx
for event handlers which need additional effects and coeffects.
pure
is largely invisible because it gets "installed" by re-frame.core/register-handler
. So too it will be for fx
.
A new registration function, re-frame.core/register-handler-fx
(notice that -fx
on the end), will invisibly add fx
to the middleware stack of the registered handler.
Aside: remember that each event handler has its own middleware stack. Some can be pure
stacks, and some fx
stacks.
Use will look like this:
(register-handler-fx ;; <-- use alternative registration (-fx on the end)
:my-event
(fn [world [_ a]] ;; world, instead of db
{:db (assoc (:db world) :flag a)
:dispatch [:do-something-else 3]})) ;; return effects
Aside: Instead of fx
I almost named it io
, which is a nod towards Haskell.
The language. Not James Haskell, the current
English Rugby openside breakaway, who I often claim is functional but lazy. My Rugby
friends have no idea what I'm talking about - "Mike, you're being too harsh.
He's in career-best form". Honestly, its pearls before swine.
fx
will need to:
- provide a
world
of{:db db}
. This is what the rest of the middleware stack will see, including the handler being registered. - interpret and action the side effects returned by the handler
Here is an initial sketch of fx
in code:
(defn fx
[handler]
(fn io-handler
[app-db event-vec]
(let [world {:db @app-db}
result (handler world event-vec)] ;; call the event handler with world
; HERE IS WHERE WE ACTION THE EFFECTS in result
(if-let [db (:db result)]
(reset! app-db db))))
If writing middleware is a mystery to you, it doesn't matter. Just know that we now
have to solve a problem: how should fx
action the side effects returned by an event handler.
fx
may get a return
value like:
{:db X
:http Y
:dispatch Z}
The :db
part is easy. But
what about :http
and :dispatch
. And what to do if result
contains a key like :abc
? The set of possible effects is open ended.
re-frame provides a function of registering handlers for effects: reg-fx
Here's how you'd use reg-fx:
(re-frame.core/reg-fx
:http ;; the key (in an effects map)
(fn [val] ;; this handler knows how to handle :http
....))
If fx
was subsequently processing a returned effects map containing an :http
key, it would look up the handler registered for :http
and it would call it, passing in the value of that key as the one argument.
Just to be clear, if the effect looked like:
{:db {}
:dispatch [:they-said-yes]
:http {:method :get
:url "http://google.com/secret-dark-web/naughty-bits"}}
Then fx
would handle :http
by calling the registered handler with one argument:
{:method :get
:url "http://google.com/secret-dark-web/naughty-bits"}
Similarly, it would also handle :db
and dispatch
via the associated registered handlers.
There isn't an ordering.
At this point, re-frame does not allow you to control the order in which side effects occur. This is arbitrary. If you feel you need this feature, really need it, then please open an issue and explain the usecase. It isn't in there because we don't have a usecase.
There will be times when you want to turn effects into noops. Testing perhaps? Replaying events?
Here's how :http
would be turned into a noop
(re-frame.core/reg-fx :http (fn [val])) ;; handler does nothing
Ramblings below . Don't read.
Whatever solution we create has to be both extensible and swapable.
Here's some candidate side-effects:
- dispatch
- flush-dom (force dom writes)
- undo
- localstore writes
- database queries (rethinkdb) ?
- http get or post
- cookies
- event sniffing
But this list is open ended. Your app may need others.
So, the mechanism fx
uses for knowing about, and handing, side-effects
will have to be extensible. Plugable.
When fx
finds that the returned map has an :http
key, it needs to
look up the Side-Effect Handler which can implement the side effect.
So this is how you would register a Side-Effect Handler:
(reg-fx-handler
:http ;; if present in world
(fn [world fx-config]
....))
There might be standard effect handlers provided. But you can write your own.
Sometimes you don't want the side-effects to happen. Sometimes, if you are testing or tracking down a bug, by replaying events, you don't want the database mutation side-effects to actually run.
So we need a way to switch side-effect instructions into noops. And then back again into being performed.
XXX how would you do multiple GET ? Give a vector I guess
XXX should they be given the entire returned value, or just their part of the try?
XXX the process of turning them into Noops still has me puzzled. How to do that nicely.
(register-event
:my-event ;; no explicit fx middleware
:fx [:db :http :dispatch]
[XXXXXX]
(fn [world [_ a]]
{:db (assoc-in world [:db :flag] true)
:dispatch [:do-something-else 3]}))
(reg-event
:my-event ;; no explicit fx middleware
(fx :http :dispatch :db)
(fn [world [_ a]]
{:db (assoc-in world [:db :flag] true)
:dispatch [:do-something-else 3]}))
(reg-event-fx
:my-event
(fn [world [_ a]]
{:db (assoc-in world [:db :flag] true)
:dispatch [:do-something-else 3]}))
Ordering
1
(fx-order :http) etc
Or some configuration option ?
Or if there is an :ordering defined use that.
Or if the world contains this ordering then use that XXX Ths is back to use of middleare.
FSM
States Triggers are events. There are actions which are performed on transition There is some "state"
Actions
- register-handler => register-event
- create register-event-fx
- Create an
fx
middleware. - Create (swap-fx-config! XXX)
- Create a (register-fx :http (fn [world]
- Create a (register-fx :dispatch (fn [world] YYYY)
- Create a (register-fx :event-forward (fn [world] XXX))
Deprecated Tutorials:
Reagent: