The Clojure Datastar Experiment: When Language Loyalty Becomes a Trap

I spent weeks building what I thought would be the obvious next step: a Clojure-native version of Datastar. Same architecture, but with Clojure expressions instead of JavaScript, Transit instead of JSON, hiccup helpers instead of raw HTML attributes.

It was a waste of time. Here's why.

The Siren Song

Datastar is a ~10KB JavaScript library for server-driven UI. The server owns all state. The client is a thin rendering layer. Click a button, POST to the server, receive SSE updates, morph the DOM. No React, no Redux, no client-side state management.

For a Clojure developer, there's a natural next question: What if we had this, but in Clojure?

The pitch writes itself:

I called the experiment Aleth and got to work.

What I Built

The core took about 2,000 lines across client and server modules:

Client modules:
  core.cljs    482 lines  (bindings, discovery)
  eval.cljs    308 lines  (expression evaluator)
  local.cljs   229 lines  (local signals)
  signals.cljs 164 lines  (signal store)
  sse.cljs     194 lines  (SSE client)
  morph.cljs   160 lines  (DOM morphing wrapper)

Server modules:
  core.clj     117 lines
  hiccup.clj   290 lines
  sse.clj      211 lines

Total: 2,155 lines

The expression evaluator alone - allowing Clojure syntax in DOM attributes - required:

When the "server owns truth" principle made simple UI patterns sluggish (every dropdown toggle required a server round-trip), I implemented local signals - Datastar's solution to the same problem:

;; Aleth's attempted local signals
[:div (a/local {:_open false})
 [:button (a/on-local :click '(toggle! :_open)) "Toggle"]
 [:div (a/show-expr '_open) "Content"]]

Four hundred lines later, it worked. I felt accomplished.

Then I looked at the bundle size.

The Numbers Don't Lie

LibrarySize (gzipped)Ratio
Datastar~10.76 KB1x
Aleth80 KB7.4x

The gap is structural, not fixable. Aleth includes:

Even stripping everything optional, ClojureScript's baseline makes parity impossible.

The Fundamental Problem

Every Aleth expression goes through:

String -> cljs.reader/read-string -> AST -> tree-walk evaluation -> result

Every click, every reactive update, every signal change pays this parsing tax. I'm interpreting an interpreter.

Datastar's expressions are native JavaScript:

$_open = !$_open

Evaluated by the browser's JavaScript engine via Function() constructor. Zero parsing overhead. Battle-tested. Every edge case handled by decades of browser development.

You cannot beat native JavaScript at being JavaScript.

This should have been obvious from the start. I was so focused on the elegance of unified syntax that I ignored the fundamental constraint: the browser already has an expression language. It's optimized. It works. Adding a layer on top is pure overhead.

The Honest Comparison

When I forced myself to answer "What does Aleth offer over Datastar?", the answer was deflating:

AspectAlethDatastar
DOM morphingIdiomorphIdiomorph (same)
SSE protocolCustom eventsCustom events (same)
Declarative attributesYesYes (same)
Local signalsYesYes (same)
Bundle size80KB10KB
Expression parsingCustom interpreterNative browser
CommunityJust meGrowing ecosystem
Backend SDKsClojure onlyGo, Python, PHP, Java, etc.

The differentiator is "Clojure syntax for expressions." That's it. And that differentiator adds complexity, size, and overhead without benefiting users.

The Trap Pattern

I fell into a trap I've seen before. Call it "language loyalty syndrome."

The pattern:

  1. Discover a tool that works well
  2. Notice it's not in your preferred language
  3. Conclude the solution is to rewrite it
  4. Spend weeks reimplementing what already exists
  5. End up with a worse version that you now have to maintain

The justification sounds reasonable: "We'll have Clojure all the way down!" But the justification conflates two different things:

Nobody writes complex logic in Datastar expressions. They're not meant for that. The server handles complexity. The client handles $visible = true.

Optimizing for "Clojure syntax" in the client is optimizing for something that doesn't need optimization.

What I Should Have Built

The valuable part of Aleth is the server side:

;; This is actually useful
(defn counter-view [count]
  [:div {:data-signals (json/encode {:count count})}
   [:button {:data-on-click "$count++"} "+"]
   [:span {:data-text "$count"} count]])

;; SSE helpers are useful
(a/sse-response
  (fn [sse]
    (a/patch! sse "#counter" (counter-view new-count))))

A Clojure SDK for Datastar would be:

Use Datastar's 10KB client as-is. Don't rewrite it. Don't wrap it. Include the CDN script and move on.

The Lesson

Before rewriting an existing tool in your preferred language, ask:

  1. Where is the value? (Server logic vs. client expressions)
  2. What am I actually gaining? (Syntax consistency? Is that worth 8x bundle size?)
  3. What is the maintenance cost? (Tracking upstream changes, fixing edge cases, security audits)
  4. Who benefits? (You as the developer, or actual users?)

The value of server-driven UI is in the architecture, not the syntax. Datastar already nailed the architecture. Wrapping it in Clojure syntax adds complexity without improving the architecture.

The Hard Part

Abandoning the experiment was harder than I expected. I had working code. I had solved interesting problems. The expression evaluator was elegant in its way.

But "I built a thing" is not the same as "I built a thing worth using."

The honest answer to "what if Datastar, but in Clojure?" is: "Use Datastar. Write your server in Clojure. The expressions on the client are JavaScript, and that's fine."

Sometimes the right answer is to not build the thing.


The Aleth experiment is preserved at github.com/parenstech/aleth. The server-side SDK approach - hiccup helpers and SSE middleware for Datastar - is what I'll build next.

Published: 2026-01-01

Tagged: clojure experiment datastar clojurescript


Comments

Archive