Reagami: a Reagent-like library in less than 100 lines of Squint CLJS

Recently I've been excited about Eucalypt, a Reagent-clone in pure Squint. For those who don't know Squint, it is a CLJS dialect that re-uses JS features as much as possible. Maps and vectors compile directly to objects and arrays for example. The standard library mimics that of Clojure(Script). Collections, although they are mutable in JS, are treated immutably via shallow-copying. You can build small apps in Eucalypt starting at about 9kb gzip. When looking at the code, I wondered if we could make an ultra-simple Reagent-like library with less features for even smaller apps. If I understand Eucalypt correctly, it produces DOM nodes from hiccup and then patches the already mounted DOM nodes using a diffing algorithm. I tried to do this in under 100 lines, in a more basic fashion. I call this library Reagami, a library you can use to fold your state into the DOM!

Let's explain how it works.

Rendering Hiccup to DOM nodes

Below you see the create-node function, which takes hiccup, e.g. [:div "Hello"] and transforms it into a DOM node. I placed comments in the code that help you understand it, hopefully.

(defn- create-node
  ([hiccup] (create-node hiccup false))
  ([hiccup in-svg?]
   (cond
;; we compile primitives directly into TexNodes
     (or (nil? hiccup)
         (string? hiccup)
         (number? hiccup)
         (boolean? hiccup))
     (js/document.createTextNode (str hiccup))
;; vectors indicate a DOM node literal or a function call
     (vector? hiccup)
     (let [[tag & children] hiccup
;; here we pull apart id and class shortcuts like: `:div#my-id.my-class`
           [tag id class] (if (string? tag) (parse-tag tag) [tag])
           classes (when class (.split class "."))
;; hiccup vectors can have an optional map to set attributes of the DOM node
          [attrs children] (if (map? (first children))
                              [(first children) (rest children)]
                              [nil children])
;; SVG needs special handling, SVG nodes need to be created with `createElementNS`, see below
           in-svg? (or in-svg? (= :svg tag))
;; if we encounter a function as the first element, e.g. `[my-component]`, we call it
           node (if (fn? tag)
                  (let [res (apply tag (if attrs
                                         (cons attrs children)
                                         children))]
;; and then we feed the result back into create-node
                    (create-node res in-svg?))
;; so here we create the DOM element, either a normal or an SVG one
                  (let [node (if in-svg?
                               (js/document.createElementNS svg-ns tag)
                               (js/document.createElement tag))]
;; here we iterate over all the children in the hiccup vector
                    (doseq [child children]
;; in squint, vectors are also seqs, but here we want to make sure the result is list-like,
;; e.g. the result from `(for [i xs] [my-component])`
;; if that is the case, then we map create-node over those results
                      (let [child-nodes (if (and (seq? child)
                                                 (not (vector? child)))
                                          (mapv #(create-node % in-svg?) child)
;; else, we have only one node as the result of calling create-node on the child
                                          [(create-node child in-svg?)])]
;; let's add all the child-nodes to the DOM element created from the tag!
                        (doseq [child-node child-nodes]
                          (.appendChild node child-node))))
;; attribute handling: for each key and value in the attribute map:
                    (doseq [[k v] attrs]
                      (let [key-name k]
                        (cond
;; Let's create some CSS if the style attribute is a msp!
                          (and (= "style" key-name) (map? v))
                          (doseq [[k v] v]
                            (aset (.-style node) k v))
;; if the attribute starts with "on", then we add an event handler
                          (.startsWith key-name "on")
;; we support :on-click, :on-mouse-down, :onMouseDown, etc.
                          (let [event (-> (subs key-name 2)
                                          (.replaceAll "-" "")
                                          (.toLowerCase))]
                            (.addEventListener node event v))
;; if the attribute false is false or nil, we ignore it
                          :else (when v
                                  (.setAttribute node key-name (str v))))))
;; classes parsed from the tag, e.g. `:div.my-class1.my-class2`
                    (let [class-list (.-classList node)]
                      (doseq [clazz classes]
                        (.add class-list clazz)))
;; finally we handle the id parsed from the tag, e.g. `:div#my-id`
                    (when id
                      (set! node -id id))
                    node))]
       node)
     :else
     (throw (do
              (js/console.error "Invalid hiccup:" hiccup)
              (js/Error. (str "Invalid hiccup: " hiccup)))))))

That's it! Pretty simple.

Patching the DOM

If we simply just re-insert the generated hiccup DOM nodes into the DOM every time something changes, then we have several problems. E.g. when we are typing in an input field and we insert a new input field, we lose focus. Also we lose event handlers, although this isn't really an issue if we re-generate them via the hiccup-to-DOM function above. A better approach is to cleverly patch the DOM and re-use existing DOM nodes when possible.

The patch function takes a parent node, some child nodes and tries to re-use or insert the children. There are several libraries that do this far more intelligently than the below function. E.g. Idiomorph is such a library. One problem I encountered with Idiomorph is that it doesn't preserve event listeners. There's also Snabbdom, a small but effective vDOM library that you could use. But where's the fun in that, if you can make your own naive patch function. Here we go! When we render a component for the first time, Reagami calls patch with the root node (usually something like (js/document.querySelector "#app")) and the rendered hiccup as the only child. Read along with the comments again.

(defn- patch [parent new-children]
;; First we take a look at the current children of the root node, already mounted in the DOM
  (let [old-children (.-childNodes parent)]
;; If the amount of children isn't the same, we just give up and replace all the children with the new children.
;; This typically happens on first render or when elements disappear due to conditionals in the hiccup code.
;; More mature patching libraries do much more clever things here
;; but I've found this works well enough for the basic use cases I tested.
    (if (not= (count old-children) (count new-children))
      (parent.replaceChildren.apply parent new-children)
;; When the number of children is the same we compare them one by one.
      (doseq [[old new] (mapv vector old-children new-children)]
        (cond
          (and old new (= (.-nodeName old) (.-nodeName new)))
;; Only when the node names are the same, we consider it the same node.
;; If it's Text node, we need to copy over the text content.
          (if (= 3 (.-nodeType old))
            (let [txt (.-textContent new)]
              (set! (.-textContent old) txt))
            (let [new-attributes (.-attributes new)
                  old-attributes (.-attributes old)]
;; Here we just set all the attribues from the new node to the old node.
              (doseq [attr new-attributes]
                (.setAttribute old (.-name attr) (.-value attr)))
              (doseq [attr old-attributes]
;; When an existing attribute in the old node, doesn't occur in the new node, we remove it
                (when-not (.hasAttribute new (.-name attr))
                  (.removeAttribute old (.-name attr))))
;; Then we recursively descend into the children of the new node.
              (when-let [new-children (.-childNodes new)]
                (patch old new-children))))
;; If the node names aren't the same, we replace the node completely with the new node.
          :else (.replaceChild parent new old))))))

Putting it all together

Reagami's only public function is called render which is just:

(defn render [root hiccup]
  (let [new-node (create-node hiccup)]
    (patch root [new-node])))

With this single function, you can write a small reactive app, if we combine it with atom and add-watch. Here's an example of this:

(ns my-app
  (:require ["https://esm.sh/reagami@0.0.8" :as reagami]))

(def state (atom {:counter 0}))

(defn my-component []
  [:div
   [:div "Counted: " (:counter @state)]
   [:button {:on-click #(swap! state update :counter inc)}
    "Click me!"]])

(defn render []
  (reagami/render (js/document.querySelector "#app") [my-component]))

(add-watch state ::render (fn [_ _ _ _]
                            (render)))

(render)

(Open this example on the Squint playground)

That's it: a Reagent-like library in less than 100 lines of code!

Further ideas:

Examples

Here are some examples you can play with on the Squint playground:

Published: 2025-10-24

Tagged: clojure reagent clojurescript squint

Archive