Fold your state into the DOM!
A minimal zero-deps Reagent-like in Squint and CLJS.
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)}] :stylemaps:{:style {:background-color :green}}:on-renderhook. 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+renderon regular atoms or you callrenderyourself. - 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.
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,:updateor:unmountdata: the result of the:on-renderfunction 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:unmountlifecycle.
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.
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])]The patch algorithm works as follows. When re-rendering elements on the screen, Reagami compares them with the previous corresponding elements.
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.
The unkeyed algorithm matches children by position.
- 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. - The shared prefix is patched index-wise using
patch-node. At each indexpatch-nodeis applied to the corresponding elements. See above for howpatch-nodeworks. - 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.
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)
- Match each new child to an old node and note that old node's position. Positions are
1-based, since0marks a new node. Each matched old node is reused and patched toward its new vnode withpatch-node(attributes and children updated, recursively). A new child with no match is built withcreate-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]
-
Remove old nodes that weren't matched.
zwas not matched, so it is removed. -
Find the longest increasing subsequence of the old positions, skipping the
0holes. This is the largest set of nodes already in the right relative order. Relative here means: there can be other positions in between. In1 4 2 3 6 _ _the longest increasing subsequence is1 2 3 6: that isa,b,cand(u). The4(noded) is left out. These four will not move. -
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 noinsertAfter), 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 isnull.parent.insertBefore(node, null)is identical toappendChild: 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.
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]
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]
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 on the Squint playground:
- Input field + counter
- Boring crud table
- Snake game
- Draggable button
- CSS transition
- Ohm's law
- Multi select
- Web component
MIT