November 29, 2024

Testing out replicant

replicator

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.

I’m so lost
[: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.

Official docs
(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.

  1. We send in a vector of vectors

  2. We give actions (commands on what we want the program to do) in the vector

  3. 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.

Tags: clojure