Skip to content

borkdude/reagami

Repository files navigation

Reagami

npm

Fold your state into the DOM!

A minimal zero-deps Reagent-like in Squint and CLJS.

Usage

Quickstart example:

(ns my-app
  (:require ["https://esm.sh/reagami" :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)

In ClojureScript you would add this library to your deps.edn :deps as follows:

io.github.borkdude/reagami {:git/sha "<latest-sha>" :git/tag "<latest-tag>"}

and then require it with (:require [reagami.core :as reagami]).

Reagami supports:

  • Building small reactive apps with the only dependency being Squint or CLJS. Smallest app with Squint after minification is around 5kb gzip.
  • Rendering hiccup into a container DOM node. The only public function is render.
  • Event handlers via :on-click, :on-input, etc.
  • Default attributes: :default-value, etc. for uncontrolled components
  • Id and class short notation: [:div#foo.class1.class2]
  • Disabling properties with false: [:button {:disabled (not true)}]
  • :style maps: {:style {:background-color :green}}
  • :on-render hook. See docs here.
  • Keyed children for better diffing via :key. See docs here.

Reagami does NOT support:

  • Auto-rerendering by auto-watching custom atoms. Instead you use add-watch + render on regular atoms or you call render yourself.
  • React hooks (it doesn't use React)

Local state can be accomplished by using nested renders like in this example or using web components.

Reagami's patching algorithm is detailed in the Patch algorithm section.

For a more fully featured version of Reagent in squint, check out Eucalypt.

:on-render

The :on-render hook can be used to do something after a DOM node is mounted, updated or unmounted. It takes 3 arguments: (fn [node lifecycle data])

  • node: the DOM node that is mounted, updated or unmounted.
  • lifecycle: one of :mount, :update or :unmount
  • data: the result of the :on-render function every time it is called. By returning data you can pass data from one lifecycle to another. E.g. when you mount a JS component, you can return {:unmount unmount} so you can call the unmount function in the :unmount lifecycle.

Example:

(fn [node lifecycle {:keys [unmount updates] :as data}]
  (case lifecycle
    :mount
    {:unmount (install-clock! node)
     :updates 0}

    :update
    (update data :updates inc)

    :unmount
    (do
      (println "Number of updates in total: " updates)
      (unmount))))

See a full working example on the playground.

Keyed children

You can add a :key property to your elements to identify nodes. This will result in better performance when Reagami re-renders.

[:ul
 (for [{:keys [id label]} items]
   [:li {:key id} label])]

Patch algorithm

The patch algorithm works as follows. When re-rendering elements on the screen, Reagami compares them with the previous corresponding elements.

patch-node

For a single node, patch-node decides reuse of an existing node or to create one from scratch. Two text nodes reuse the old node and update its text. Two elements with the same tag reuse the old node, sync its attributes and recurse into its children. In other situations a new node is created and the old one is discarded. When a node is reused, the children must also be reconciled. Reagami first checks whether any child has a :key. If at least one keyed child is present, the keyed algorithm is used, else the unkeyed (positional) algorithm.

Unkeyed

The unkeyed algorithm matches children by position.

  1. First the shared prefix is determined: the leading positions present in both lists, that is the first min(old-count, new-count) children. For old [a b c] and new [a b] that is the first two positions, and for old [a b] and new [a b c] also the first two. The prefix is positional, the nodes at those positions need not match.
  2. The shared prefix is patched index-wise using patch-node. At each index patch-node is applied to the corresponding elements. See above for how patch-node works.
  3. After that, there can be two cases to handle: a. There are more new children than old: extra new nodes are created + appended. b. There are more old children than new: extra old nodes are removed. One special case of this is that there are 0 new children in total, so all old children must be removed. Reagami then uses parent.textContent = "" as an optimization.

Example: old [a b c], new [d e]. Positions 1 and 2 overlap, so a is patched toward d and b toward e using patch-node. The same tag means the node is reused and updated, a different tag means the new node replaces the old. Position 3 (c) is removed.

The unkeyed algorithm doesn't move any nodes, so expensive collapses can happen, e.g. when a new node must be inserted at or near the front. In a situation where extra performance is needed, add :keys so the keyed algorithm will be used, which can reliably move nodes around.

Keyed

The keyed algorithm is inspired by Vue3 and uses the Longest Increasing Subsequence algorithm.

When any child has a :key, the whole list is reconciled by key. Keyed and unkeyed children can be mixed, although it's recommended to use keys on all the elements. The unkeyed elements are matched positionally. The algorithm is best shown with an example. Below, a plain letter is a keyed node whose key is that letter, and a parenthesised letter like (u) is an unkeyed node.

Let's say we have the following situation:

old:  a    b    c    d    z    (u)
new:  a    d    b    c    (u)  n    (m)
  1. Match each new child to an old node and note that old node's position. Positions are 1-based, since 0 marks a new node. Each matched old node is reused and patched toward its new vnode with patch-node (attributes and children updated, recursively). A new child with no match is built with create-node.
a    ->  old a    pos 1    by key
d    ->  old d    pos 4    by key
b    ->  old b    pos 2    by key
c    ->  old c    pos 3    by key
(u)  ->  old (u)  pos 6    next unused unkeyed old, taken in order
n    ->  create   pos 0    no old node with key n
(m)  ->  create   pos 0    no unkeyed old left

old positions (in new order) = [1 4 2 3 6 0 0]
  1. Remove old nodes that weren't matched. z was not matched, so it is removed.

  2. Find the longest increasing subsequence of the old positions, skipping the 0 holes. This is the largest set of nodes already in the right relative order. Relative here means: there can be other positions in between. In 1 4 2 3 6 _ _ the longest increasing subsequence is 1 2 3 6: that is a, b, c and (u). The 4 (node d) is left out. These four will not move.

  3. Place nodes right to left into the parent, moving only the ones outside that subsequence. The DOM can only insert a node before a reference node (insertBefore, there is no insertAfter), so each node is anchored on its right neighbour. Therefore we iterate from right to left to ensure the right neighbour is already in place. The rightmost node has no right neighbour, so its reference is null. parent.insertBefore(node, null) is identical to appendChild: with no node to go before, it goes at the end.

(m)  new             ->  append (insertBefore null)
n    new             ->  insertBefore (m)
(u)  in subsequence  ->  leave in place
c    in subsequence  ->  leave in place
b    in subsequence  ->  leave in place
d    reused, moved   ->  insertBefore b
a    in subsequence  ->  leave in place

Result: a d b c (u) n (m), with z removed. Only d was moved, n and (m) were created, and the rest never moved.

Benchmarks

In the below benchmarks, Reagami is compared against other CLJS UI libraries with js-framework-benchmark and used the keyed variant. Reagent, Helix and UIX are tested with React 19.2. A more detailed explanation of the nethodology and how you can run it yourself are in doc/benchmarks.md.

Geometric mean across the nine keyed table operations (lower is better):

---
config:
  xyChart:
    width: 850
    height: 480
  themeVariables:
    xyChart:
      plotColorPalette: "#ff7f0e, #4c78a8"
---
xychart-beta
    title "Perf: geomean of 9 keyed ops (ms, lower is better)"
    x-axis ["UIX", "Helix", "Reagami Squint", "Reagent", "Reagami CLJS", "Replicant Squint", "Replicant CLJS"]
    y-axis "ms" 0 --> 60
    bar [-5, -5, 38.4, -5, 43.0, -5, -5]
    bar [32.3, 36.0, -5, 42.6, -5, 52.0, 56.0]
Loading

The same data-table app was compiled with production settings. Below we compare the output size, gzipped.

---
config:
  xyChart:
    width: 850
    height: 480
  themeVariables:
    xyChart:
      plotColorPalette: "#ff7f0e, #4c78a8"
---
xychart-beta
    title "Bundle size (gzip KB, lower is better)"
    x-axis ["Reagami Squint", "Replicant Squint", "Reagami CLJS", "Replicant CLJS", "UIX", "Helix", "Reagent"]
    y-axis "KB" 0 --> 100
    bar [7.9, -5, 28.7, -5, -5, -5, -5]
    bar [-5, 16.9, -5, 75.9, 91.7, 98.4, 99.5]
Loading

The minimal Reagami app under Squint is smaller, around 5 KB gzip, but in the benchmark the js-framework-benchmark's standard table app is compared.

As you can see Reagami on Squint can perform in the ballpark of modern CLJS React or React-free alternatives, yet is the leanest when it comes to output size. Performance on Squint tends to be a tad faster than on CLJS too.

Examples

Examples on the Squint playground:

License

MIT

About

A minimal zero-deps Reagent-like for Squint and CLJS

Topics

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors