Testing out replicant
The SPA library landscape is pretty varied in Clojurescript, as long as you like using React underneath the hood. This particular fact is what makes replicant so interesting. Replicant has zero dependencies and is written in Clojurescript from the ground up.
Functional Core, Imperative Shell
I started working with javascript the year 2006, using prototype.js, before jQuery become the de facto standard for frontend work, before React stole the throne. Of all the javascript/clojurescript libraries/frameworks I’ve worked with since then, replicant stands out as the one library that takes the pattern of Functional Core, Imperative Shell to its most extreme. Every library/framework I’ve worked with relies on some form of mutation, either local or handled by some form of state management system/library, tightly intertwined with the rendering part of the stack.
Replicant takes the opposite approach and allows zero local state. Even the event handlers are just pure data, and the events are offhanded to a single global dispatch function that you have to setup yourself, manually… You also have to handle the dispatch yourself. This is very different from other libraries/frameworks where everything is handled for you, and you hook in your code instead.
Benefits
This approach yield some benefits. In no particular order they are:
Testable
With no side effects allowed, everything becomes easily testable. You’re not dependant on any global state to do the tests of your frontend code. At all.
Easy to reason about
As you only need the data sent in to see the result of the code. This makes it very REPL friendly, easy to reason about in isolation, and very malleable once you start building larger pieces of GUI.
Declarative
With the event handlers being pure data, you now declare what you wish to happen, instead of writing it out in the code on the spot.
This has two benefits. First is that it’s easier to read what the code does at a glance. Second is that it enforces the gathering of implementations for mutations in one place.
Interop
You are free to interop with any third party library.
Event handling
You have to implement your own event handling. You can now make it work exactly as you want it.
Costs
There is no such thing as a free lunch.
Event handling
You have to implement your own event handling. You can now bask in the glory of your mistakes.
Interop
You have to interop if you choose a third party library to use. This will be sitting at the edge of your program, and you now (most likely) have to introduce some form of state mananagement.
Event handlers
OK. So event handling becomes a thing. Speaking with Peter Strömberg (aka PEZ, aka @pappapez on X), he was kind enough to show some of his thoughts on how to handle it. I really liked the approach and ran with it.
[:button {:on {:click ???}}]
This is how replicant handles events. You provide a map of events with
something
that is sent to the global dispatch. The dispatch takes
two arguments: A context map (with replicant data) and the data you
provided the event handler.
What do we put in place of something
? Remember… this is pure
data. We can send keywords, maps, vectors, lists, etc. We can’t send
the event or anything to do with it.
(require '[replicant.dom :as replicant])
(replicant/set-dispatch!
(fn [replicant-data handler-data]
(prn "Click!")))
(replicant/render
(js/document.getElementById "app")
[:h1 {:on {:click [:whatever]}} "Click me"])
The official documentation gives this is an example of how you could handle a click event.
Basking in the glory of my mistakes
This is the approach I’ve currently landed on. PEZ gave me three ideas to work with.
We send in a vector of vectors
We give actions (commands on what we want the program to do) in the vector
We enrich data in the vector
Vector of vectors
Sending in a vector of vectors, where each vector is an action, means that we can now chain events. We might want to send some form data to the backend over HTTP, show a spinner and stop the spinner when we get a response. These are three separate actions that are all complected into the press of the submit button. With a vector of vectors we can express this as such:
[:button {:type :submit
:on {:click [[:http/post "/my/form" :form/data
[[:ax/hide :spinner]]
[[:ax/hide :spinner]]]
[:ax/show :spinner]]}}
"Submit"]
I have a HTTP POST to /my/form
with my data. In addition I send in
two new actions to be executed if either the POST is successful or
returns an error. These actions have to be handled by the handler
paired with :http/post
.
I set up the spinner to show automatically.
Actions
In the above example I have three actions: :http/post
, :ax/show
and :ax/hide
. Each action will correspond to a function that takes
the arguments given in the vector. Add in the database that you have
and you now have mutations, all handled in one place.
Enrichment
How do we handle :form/data
in the above code snippet? This is where
enrichments come in. An enrichment is the idea of taking a piece of
data in what’s been sent in, and enrich it with data from somewhere
else.
In the case of :form/data
the data will most likely come from the
database. But this is also what we would use to capture data from
events with the enrichment :event.target/value
. If we have interop
with third party libraries, we can add enrichments from those as well.
:form/data
is a bit poor, and was used more as a way to illustrate a
point. You would probably use this instead: [:http/post "/my/form"
[:db/get :form/data] [[:ax/hide :spinner]] [[:ax/hide
:spinner]]]
. Remember, we can nest our vectors as much as we want.
Convey
I’ve put together an experimental library for this approach at GitHub.
Highlights
Stole Borrowed the event router from re-frame (piece of art)
Enrichments are extendable and implemented with a zipper, allowing for skipping data when needed (such as a massive amount of response data)
Easy to enforce synchronous behavour on an action via metadata (using
^:sync
). Works for both individual vectors and an entire collection of vectors.Actions are implemented with multimethods, allowing for easy extension
Optional rendering is done after a vector of actions have been executed.
Gives optional hook to dispatch events that are outside the rendering loop (such as HTTP communication)
Allows for third party library integration for state handling
Convergence
(defmethod action! ::render [_ render-fn state* _]
(render-fn @state*))
(defn sync? [actions]
(true? (:sync (meta actions))))
(defn prepare-state-default [state _ctx]
state)
(defn init-event-handler [db ctx render enrichments event-queue]
(let [prepare-state (ctx :prepare-state prepare-state-default)]
(fn [replicant-data actions & [flush?]]
(if flush?
(router/flush! event-queue)
(doseq [action actions]
(let [action-ctx (merge (assoc replicant-data :db db) ctx)
[action-name & args] (enrich-actions action-ctx enrichments action)]
(if (or (sync? action)
(sync? actions))
(action! action-name args db replicant-data)
(router/push event-queue [action-name args db replicant-data])))))
(when render
(router/push event-queue [::render render (delay (prepare-state @db ctx))])))))
action!
The action!
multimethod takes four arguments: action-name
,
action-args
, state
and context
.
When you send in a vector like [:ax/show :spinner]
it transforms
into action-name
as :ax/show
and action-args
as (:spinner)
.
state
is the current snapshot of the database.
context
is the map with the context data. This is where you would
get things like replicant data, but also where you can put in your own
data, such as data from HTTP responses.
prepare-state
This is for extending state management. Put in datascript or something else and have it applied to the current snapshot state when rendering.
doseq actions
In here a new context is created for the action, where the data from replicant is enriched with the db and anything sent in as a context map from outside of convey.
Enrichment is also handled here.
After everything is said and done, we either execute the action directly or put it on the event router’s event queue. Also, read re-frame’s source. It’s a work of art.
render
We finally push a new action if there is a render function supplied. It’s pushed through the event router in order to make sure it’s executed after everything in the actions is executed. It’s wrapped in a delay so that any changes to the db can take place first.
Enrichment example
Without going into the details of enrichment, I’d like to show an example of how an enrichment might look.
(defn- get-event-value [e]
(try
(-> e .-target .-value)
(catch js/Error _
(try
(-> e .-detail)
(catch js/Error _
nil)))))
(defn enrich-event-value [{:replicant/keys [^js js-event]} node loc]
(if js-event
(zip/replace loc (get-event-value js-event))
node))
(def enrichments
{:event.target/value enrich-event-value})
Whenever we have [:db/assoc :user/first-name :event.target/value]
in
an action, :event.target/value
will have the above code executed,
replacing :event.target/value
with the value present in :replicant/js-event
.
enrichments
is what would be passed in to the function above in
init-event-handler
.
Closing thoughts
Replicant is a really cool piece of tech and is something I wish to use more. I like how it enforces FCIS.