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.
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.
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))))))
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:
Here are some examples you can play with on the Squint playground:
Published: 2025-10-24
Tagged: clojure reagent clojurescript squint
In this post I'll give updates about open source I worked on during July and August 2025.
To see previous OSS updates, go here.
I'd like to thank all the sponsors and contributors that make this work possible. Without you the below projects would not be as mature or wouldn't exist or be maintained at all! So a sincere thank you to everyone who contributes to the sustainability of these projects.

Current top tier sponsors:
Open the details section for more info about sponsoring.
If you want to ensure that the projects I work on are sustainably maintained, you can sponsor this work in the following ways. Thank you!
Although summer hit Europe and I made a train trip to Switzerland for some hiking with my wife, OSS activity continued in the borkiverse. 20 projects saw updates. As usual, babashka, SCI and clj-kondo saw the most activity.

One of the big things I’m looking forward to is speaking at Clojure Conj 2025. At the risk of sounding a bit pretentious, the title of my talk is "Making Tools Developers Actually Use". Babashka started as a quirky interpreter "nobody had asked for" but now many Clojure developers don't want to live without it. Clj-kondo started out as a minimal proof-of-concept linter and now is widely used tool in Clojurian's every day toolset and available even in Cursive today. In the talk I want to reflect on what makes a tool something developers (like myself) actually want to use. I'm excited about this opportunity and about my first time visiting the Conj (don't ask me how I got the Clojure Conj cap on the photo above). Given the rest of the schedule, it's something I wouldn't want to miss.
For babashka, my main focus has been making it feel even more like regular Clojure. One example is the change in how non-daemon threads are handled. Previously, people had to sometimes add @(promise) to keep an httpkit server alive. Now babashka behaves like clojure -X in this regard: if you spawn non-daemon threads, the process waits for them. It’s looks like a small change, but it brings consistency with JVM Clojure, something I'm always aiming for more with babashka. If you want the old behavior, you can still use --force-exit. While implementing this I hit an interesting bug with GraalVM and also found out that clojure -X sometimes stalls when using agents. Maybe more on this next time.
Another change that was introduced is that when code is evaluated through load-string or Compiler/load (which is the same thing in bb), vars like *warn-on-reflection* are bound. This fixes a problem with loading code in non-main threads. E.g. @(future (load-string "(set! *warn-on-reflection* true)")) would fail in previous versions of babashka. You might wonder why you would ever want to do this. Well, a similar thing happens when you execute babashka tasks in parallel and that's where I ran into this problem.
SCI, the interpreter under the hood of babashka and several other projects, got some critical fixes as well. I detected one somewhat embarrasing bug when loading clojure+.hashp in babashka. It had code that looked like:
(def config {})
(let [config {}
_ (alter-var-root #'config (constantly config))
]
...)
In the expression (alter-var-root #'config (constantly config)) the var #'config was mistaken for the local config since SCI's analyzer used a resolve-like function that also resolves locals. This fails horribly. In 6 years of SCI it's the first time I encountered this bug though. After fixing this problem, I noticed that babashka's CI acted up. On every commit, babashka CI tests dozens of Clojure libraries by running their test suites. I noticed that specter's tests were failing. It turned out that one test actually worked prior to fixing the above bug exactly because the SCI analyzer's resolve returned a node that evaluated to a local value. But there is no way I could just leave that bug in, so I had to make a pull request to specter as well to set this straight. A new specter version was released that works both with older version of babashka and the new version.
One other headscratcher in SCI was on the ClojureScript side of things and had to do with munging. In interop like (.-foo-bar #js {:foo-bar 1}) ClojureScript munges the field name in the interop form to foo_bar but in the object it stays "foo-bar". The munging of this name wasn't applied in SCI as an oversight. So in SCI (and thus in nbb, joyride, scittle, etc.) the above expression would return 1 whereas in ClojureScript it would return nil. In contrast, (.-foo-bar #js {:foo_bar 1}) would return nil in SCI but 1 in CLJS. Although fixing this could mean a breaking change in SCI-based scripting environments I decided to align it with CLJS anyway, as switching between SCI and CLJS should not introduce these kinds of surprises.
Other improvements in SCI were made in the area of better using type hints on instance method interop.
And then there’s clj-kondo, the linter that is supposed to spark joy ✨, as far as a linter is able to do that in a developer's life. Two new linters were added, including one that catches suspicious uses of locking. This linter was inspired by a similar rule in splint. Lots of smaller improvements were made like sorting findings and imported files such that they are consistent across multiple runs that use the --parallel option and across operating systems. And as usual bugfixes and preventing false positives.
One happy improvement to scittle is that referencing a library that was introduced by a <script> tag now was made a lot easier. You can find the docs about that here. The tl;dr of this is that when a library registers itself as a global, you can just use that global in :require now: (require '["JSConfetti" :as confetti]).
Of course, none of this happens in isolation. I’m deeply grateful to the community and the sponsors who make this work sustainable: Clojurists Together, Roam Research, Nextjournal, Nubank, and many other companies and individuals. Every bit of support means I can keep refining these tools, fixing edge cases, and thinking about the long-term direction.
Here are updates about the projects/libraries I've worked on in the last two months in detail.
babashka: native, fast starting Clojure interpreter for scripting.
1.12.2@(promise) anymore when you spawn an httpkit server, for example. For futures and agents, bb uses a thread pool that spawns daemon threads, so that pool isn't preventing an exit. This behavior is similar to clojure -X. You can get back the old behavior where bb always forced an exit and ignored running non-daemon threads with --force-exit.clojure.test/*test-out* to same print-writer as *out* in nREPL serverCompiler/demungeclojure.lang.TaggedLiteral/createjava.util.TimeZone/setDefaultprintln-str(.getContextClassLoader (Thread/currentThread)) should be able to return results from babashka classpathdeps.clj to 1.12.2.1565*warn-on-reflection* during load{string,reader} (same as JVM Clojure) so can load code in other than than the main threadcheshire.generate/{add-encoder,encode-str}6.8.01.3.01.21.2fs to 0.5.7cheshire to 6.1.0SCI: Configurable Clojure/Script interpreter suitable for scripting
println-strlet body:param-tags on qualified instance method*suppress-read*load-reader*loaded-libs* is now the single source of truth about loaded libs*warn-on-reflection* and bind them during load-string etc. such that set!-ing then in a future works.set! syntax in CLJSmerge-opts with :bindings + deprecate :bindings (replaced by :namespaces {'user ...})clj-kondo: static analyzer and linter for Clojure code that sparks joy.
symbol accepting var1.10.3 is the minimum clojure version:inline-def with nested deftestinline-configs config.edn in a git-diff-friendly way (@lread):locking-suspicious-lock false positives:condition-always-true false positives:locking-suspicious-lock: report when locking is used on a single arg, interned value or local object:unresolved-protocol-method. See docs (@emerson-matos)clojure.string/replace and partial as replacement fn:condition-always-true check. (@NoahTheDuke)schema.core/defprotocol (@emerson-matos)str0.10.47:deprecated-namespace for .cljc namespacesclerk: Moldable Live Programming for Clojure
squint: CLJS syntax to JS compiler
while didn't compile correctlyclojure.string/includes?ClassCastException in statement function when passed Code records:with option in require, e.g. :with {:type :json}not= as functionrandom-uuid (@rafaeldelboni)[:$ ...] tagscittle: Execute Clojure(Script) directly from browser script tags via SCI
globalThis js deps (@chr15m). See docs.(.-foo-bar {}) now behaves as {}.foo_bar, i.e. the property or method name is munged.cjohansen/dataspex plugin (@jeroenvandijk)goog.string/format (@jeroenvandijk)(set! #js {} -a 1) CLJS syntax (by bumping SCI)dev folder of the distribution + a dev/scitte.cljs-devtools.js moduleedamame: configurable EDN and Clojure parser with location metadata and more
*suppress-read*: :suppress-readsci.configs: A collection of ready to be used SCI configs.
nbb: Scripting in Clojure on Node.js using SCI
:responses key with raw responsesfs - File system utility library for Clojure
cherry: Experimental ClojureScript to ES6 module compiler
not= is now a functionCLI: Turn Clojure functions into CLIs!
:repeated-opts option to enforce repeating the option for accepting multiple values (e.g. --foo 1 --foo 2 rather than --foo 1 2)deps.clj: A faithful port of the clojure CLI bash script to Clojure
CLJ_JVM_OPTS for downloading tools jar.pod-babashka-fswatcher: babashka filewatcher pod
sci.nrepl: nREPL server for SCI projects that run in the browser
"session-closed" to close op replypod-babashka-go-sqlite3: A babashka pod for interacting with sqlite3
http-server: serve static assets
:not-found option for handling unfound files. The option is a function of the request and should return a map with :status and :body.Contributions to third party projects:
specter: Clojure(Script)'s missing piece
clojure-test-suite: Dialect-independent tests for clojure.core, and others, focused on characterizing how Clojure JVM behaves so that other dialects to reach parity.
These are (some of the) other projects I'm involved with but little to no activity happened in the past month.
Published: 2025-08-05
Tagged: clojure oss updates
In this post I'll give updates about open source I worked on during May and June 2025.
To see previous OSS updates, go here.
I'd like to thank all the sponsors and contributors that make this work possible. Without you the below projects would not be as mature or wouldn't exist or be maintained at all! So a sincere thank you to everyone who contributes to the sustainability of these projects.

Current top tier sponsors:
Open the details section for more info about sponsoring.
If you want to ensure that the projects I work on are sustainably maintained, you can sponsor this work in the following ways. Thank you!
Here are updates about the projects/libraries I've worked on in the last two months, 19 in total!
babashka: native, fast starting Clojure interpreter for scripting.
#^ metadata)satisfies? for protocol extended to nil1.2.50taoensso/trove work in bb by exposing another timbre vartimbre to 6.7.1:protocol metaprint-simple1.21.1with-redefs + intern (see SCI issue #973clojure.lang.Var/interninit as task nameclojure.lang.Var/{get,clone,reset}ThreadBindingFrame for JVM Clojure compatibilitytaoensso.timbre/spy and include testtaoensso.timbre/set-ns-min-level! and taoensso.timbre/set-ns-min-level1.12.1clojure.java.io/resource implementationjava.text.BreakIteratorjava.lang.Thread$Builder$OfPlatformjava.util.concurrent.ForkJoinPooljava.util.concurrent.ForkJoinPool$ForkJoinWorkerThreadFactoryjava.util.concurrent.ForkJoinWorkerThreadjava.util.concurrent.SynchronousQueuetaoensso.timbre/set-min-level!taoensso.timbre/set-config!fs to 0.5.26jsoup to 1.20.1edamame to 1.4.30taoensso.timbre to 6.7.0pods: more graceful error handling when pod quits unexpectedlytype should prioritize :type metadatans-name should work on symbols:clojure.core/eval-file should affect *file* during eval:init in tasks only once:init in tasks before task specific requiresresolve when *ns* is bound to symboldeps.clj to 1.12.1.1550http-client to 0.4.23SCI: Configurable Clojure/Script interpreter suitable for scripting
satisfies? for protocol that is extended to nilsci.async/eval-string+ should return promise with :val nil for ns form rather than :val <Promise>1.4.30:type key priority in type implementationns-name should work on symbols^:clojure.core/eval-file metadata should affect binding of *file* during evaluationsci.impl.Reflector with changes in clojure.lang.Reflector in clojure 1.12.1:static-methods option for class with different name in hostwith-redefs on core vars, e.g. intern. The fix for this issue entailed quite a big refactor of internals which removes "magic" injection of ctx in core vars that need it.unchecked-set and unchecked-get for CLJS compatibilityclerk: Moldable Live Programming for Clojure
quickblog: light-weight static blog engine for Clojure and babashka
--date to api/new. (@jmglov):preview optionedamame: configurable EDN and Clojure parser with location metadata and more
:imports to parse-ns-form#^:foo deprecated metadata reader macro (@NoahTheDuke)continue value that indicates continue-ing parsing (@NoahTheDuke):auto-resolve-ns affect syntax-quote:auto-resolve-ns failing casesquint: CLJS syntax to JS compiler
random-uuid (@rafaeldelboni)trampoline (@rafaeldelboni)throw in expression positionnilclj-kondo: static analyzer and linter for Clojure code that sparks joy.
:locking-suspicious-lock: report when locking is used on a single arg, interned value or local objectclojure.string/replace and partial as replacement fn:discouraged-java-method. See docs:config-in-ns on :missing-protocol-method:redundant-ignore on :missing-protocol-methodformat and whitespace flag after percent:missing-protocol-method when using alias in method:redundant-ignore aware of .cljc:missing-protocol-method linterReentrantLock to coordinate writes to cache directory within same process:langs option in :discouraged-var to narrow to specific language:ns to &env in :macroexpand-hook macros when executing in CLJS:name data to :unresolved-namespace finding for clojure-lspsci.configs: A collection of ready to be used SCI configs.
scittle: Execute Clojure(Script) directly from browser script tags via SCI
nbb: Scripting in Clojure on Node.js using SCI
import-meta-resolve to fix deprecation warnings on Node 22+jsr: and npm: deps, including react in combination with reagentnode:quickdoc: Quick and minimal API doc generation for Clojure
<sub>deps.edn2025.04.07org.babashka/cli dependencynative-image friendlynextjournal.markdown.utils:html-block) and inline HTML (:html-inline) (see #7):code according to spec into <pre> and <code> block with language class (see #39)applied-science/js-interop->hiccup functionnextjournal.markdown.transform through main nextjournal.markdown namespace:responses key with raw responseseven?http-client: babashka's http-client
:request-method in addition to :request to align more with other clients:url in addition to :uri to align more with other clientsunused-deps: Find unused deps in a clojure project
fs - File system utility library for Clojure
cherry: Experimental ClojureScript to ES6 module compiler
cherry.embed which is used by mallideps.clj: A faithful port of the clojure CLI bash script to Clojure
These are (some of the) other projects I'm involved with but little to no activity happened in the past month.
Published: 2025-07-01
Tagged: clojure oss updates