reagami
v0.2.40
Published
Maintainers
Readme
Reagami
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]).
To start a new project, run:
npm create reagami-app my-appThe command creates a Vite project with hot reload and a browser nREPL. See create-reagami-app.
Reagami supports:
- Building small reactive apps with the only dependency being Squint or CLJS. Smallest app with Squint after minification is around 5.5 KB 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. - Server-side rendering with hydration via
reagami.ssr, on the JVM, Babashka, Squint, and CLJS. See Server-side rendering.
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)
- Repairing changes made by other code. Reagami owns the children of the nodes it renders. A node that something else adds, removes or reorders stays that way.
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
Reagami calls the :on-render hook after it mounts, updates or unmounts a DOM
node. The hook takes a map:
[:div {:on-render (fn [{:keys [node lifecycle state save]}] ...)}]node: the DOM node.lifecycle: one of:mount,:updateor:unmount.state: the value that you saved on the last call. It isnilon:mount.save: call it with one value to keep that value for the next call.
Example:
(fn [{:keys [node lifecycle state save]}]
(case lifecycle
:mount
(save {:stop (install-clock! node)
:updates 0})
:update
(save (update state :updates inc))
:unmount
(do
(println "Number of updates in total:" (:updates state))
((:stop state)))))On :unmount the node is already out of the document.
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])]Server-side rendering
reagami.ssr renders hiccup to an HTML string. It runs on the JVM, on
Babashka, on Squint, and on ClojureScript. The namespace has one public
function: render.
(require '[reagami.ssr :as ssr])
(ssr/render [:div#app [:p "Hello"]])
;;=> "<div id=\"app\"><p>Hello</p></div>"To make hydration work, follow these steps:
- On the server, render the page with
ssr/render. Put the result in the container element. - On the client, call
reagami.core/renderwith that container and the same hiccup.
The ssr example shows a small server on Babashka with hydration on the client. The ssr-live example adds server state, event streams, and edits. A live instance of that example runs at https://reagami-ssr-live.michielborkent.nl.
The output follows the hydration contract:
- The output contains no
:keyattributes and no event handlers. The client adds the handlers. - A nil child becomes the empty comment
<!---->. This comment keeps the position of the child. - Numbers get the JavaScript format. The double
1.0becomes the string"1". - The value of
:innerHTMLgoes into the output without a change. - A property with no HTML form, such as
:indeterminate,:volumeand:playbackRate, is left out. The client sets it on the first render.
CAUTION: Do not put user input in tag names, in attribute names, or in
:innerHTML. Reagami escapes text and attribute values only.
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.
- 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.
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)- 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 placeResult: 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 methodology 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", "Reagami Squint", "Helix", "Reagami CLJS", "Reagent", "Replicant CLJS", "Replicant Squint"]
y-axis "ms" 0 --> 60
bar [-5, 34.0, -5, 38.0, -5, -5, -5]
bar [32.8, -5, 36.7, -5, 40.9, 45.9, 52.1]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 [9.4, -5, 28.5, -5, -5, -5, -5]
bar [-5, 16.5, -5, 40.2, 89.5, 96.1, 97.1]The minimal Reagami app under Squint is smaller, around 5.5 KB gzip, but in the benchmark the js-framework-benchmark's standard table app is compared.
Reagami on Squint is in the same range as the React libraries and the React-free alternatives. It has the smallest output size of the group. The Squint target is faster than the CLJS target.
Examples
Examples on the Squint playground:
- Input field + counter
- Boring crud table
- Snake game
- Draggable button
- CSS transition
- Ohm's law
- Multi select
- Web component
The web component example builds a <todo-list>
custom element and uses it from Squint, from JavaScript and without Reagami.
License
MIT
