§ 1 First principles
The shared substrate: what a Web Component actually is
Before comparing three libraries, it helps to know the one thing they all stand on. All three produce Web Components — and that single fact decides most of what follows.
Strip away every framework and the browser already gives you a way to invent your own HTML tags. It rests on three native platform features:
- Custom Elements — you teach the browser that
<my-thing>maps to a class (or some logic). From then on, whenever that tag appears in the page — typed by hand, injected by React, streamed from a server — the browser runs your logic automatically. No mounting step, no registry of your own. - Shadow DOM — a private subtree hanging off your element. Styles inside it don't leak out; page styles don't leak in. Your component carries its own CSS.
- Slots — holes in your shadow tree where the page's own children show through, so a parent can pass content into your component without your component re-rendering.
The payoff is portability. A web component is just an HTML element, so it works anywhere HTML works — vanilla pages, React, Vue, Svelte, a server-rendered template. There's no adapter and no framework lock-in. That is the prize all three of our libraries are reaching for. They differ only in how you author the logic behind the tag.
The mental model to hold onto
A plain web component is conceptually a function from inputs (attributes & properties) to a shadow tree. Everything in this report is a different answer to one question: how do you write that function, and where does state live?
Try it below. This is a real, native custom element defined in ~15 lines of vanilla JavaScript — no library at all. Change its attribute and watch the shadow tree re-derive itself. This is the raw clay; Atomico, Zero, and BareDOM are three ways of shaping it.
The element observes its variant attribute. Setting it re-runs render(), which rebuilds the shadow DOM. No state lives inside — the DOM is a pure function of the attribute. This is exactly BareDOM's whole philosophy, shown in miniature.
§ 2 atomico
Atomico, dismantled: functions where classes used to be
The native way to define a custom element is to write a class that extends HTMLElement, juggle this, wire up connectedCallback / attributeChangedCallback by hand, and manually touch the DOM. Atomico's pitch is: throw all of that away and write a function.
Here is the canonical Atomico component. Read it before we take it apart:
import { c } from "atomico"; const MyComponent = c( ({ message }) => ( <host> <h1>Hello {message}</h1> </host> ), { props: { message: String } } ); customElements.define("my-component", MyComponent);
Four things are happening, and each replaces a chunk of class-based ceremony:
1 · The view is a function returning a virtual DOM
The first argument to c(...) is a plain function. It takes the component's props and returns JSX. That JSX is not the real DOM — it's a lightweight description of it (a virtual DOM). Atomico diffs the new description against the last one and patches only what changed. You never call document.createElement or touch this.shadowRoot. You describe; Atomico reconciles.
2 · <host> is the component itself
Every Atomico view returns a single <host> root. If you know React, it looks like a fragment (<>…</>), but it means something more specific: <host> is the custom element instance. Attributes you put on it — styles, event handlers, the shadow-DOM configuration — apply to the element the browser created for your tag. It's the seam between "my function" and "the live element on the page."
3 · props is a typed schema, not just prop-types
The second argument declares the component's properties as a small data structure. This is doing far more than React's prop-types. From { message: String } Atomico generates:
- a real JavaScript property on the element (
el.message = "hi"works); - an observed attribute, so
<my-component message="hi">works and stays in sync; - type coercion (the string attribute
"42"becomes the number42when the type isNumber); - optionally, reflection back to the attribute, default values, and an event that fires automatically whenever the prop changes.
The fuller form shows the knobs:
MyComponent.props = {
value: { type: Number, value: 0 }, // default value
checked: { type: Boolean, reflect: true }, // mirror prop -> attribute
message: String // shorthand for { type: String }
};
4 · c(...) compiles all of it into a class
The c wrapper takes your function plus the schema and manufactures the HTMLElement subclass that the platform requires — generating the getters, setters, observed-attributes list and lifecycle plumbing you'd otherwise hand-write. You hand customElements.define the result. So Atomico hasn't escaped classes; it has hidden them behind a function-shaped door.
That hidden door is where most of Atomico's apparent magic lives. If you want to watch c() manufacture the class field by field — how the JSX becomes a vnode tree, how <host> resolves to the element, how the reactive plumbing is wired — a companion deep-dive takes it apart.
§ 3 atomico · the confusing bit, expanded
The hook machine — and why order matters
Atomico's state lives in hooks: useState, useProp, useEffect, useEvent, useHost, and friends. They're the part newcomers find magical and slightly spooky, so let's build the intuition from scratch.
A hook looks like a normal function call inside your view:
import { c, useProp, useEvent } from "atomico"; function counter() { // bind the "count" prop as if it were React state const [count, setCount] = useProp("count"); // get a function that dispatches a DOM CustomEvent const emitChange = useEvent("change", { bubbles: true }); return ( <host> <button onclick={() => { setCount(count + 1); emitChange(); }}> clicked {count} times </button> </host> ); } counter.props = { count: { type: Number, value: 0 } };
But here's the catch that confuses people: useProp("count") doesn't receive the count. It returns the current count and a setter — yet the function takes no state argument and creates no variable to store it. Where does the value come from?
The intuition: a numbered row of lockers
Picture each component instance owning a row of numbered lockers. Every time the view function runs, Atomico starts a counter at zero. The first hook call grabs locker 0, the second grabs locker 1, and so on — purely by call order. The hook reads whatever's in its locker, hands it to you, and gives you a setter that writes back to that same locker and schedules a re-render.
So a hook has no name and stores nothing in your code. Its identity is simply "the Nth hook called this render." That's the whole trick — and the source of the famous rule: call hooks in the same order every render. Put one behind an if and the locker numbers shift, so hook #2 reads hook #1's locker. State gets crossed.
The demo makes the locker model literal. “Render” walks the view top-to-bottom, assigning each hook to the next slot. Toggle the conditional hook to see the slots misalign — the bug the rule exists to prevent.
Each hook claims the next free slot as the view executes. With the conditional hook on, the slot indices shift and later hooks read the wrong locker — exactly why Atomico (like React) forbids hooks inside conditionals.
Atomico's hook palette
The rest are variations on the locker idea, each wired to a different concern:
useState— purely internal state, invisible from outside the element.useProp("x")— likeuseStatebut the locker is the public propertyx. Reading and writing it is reading/writing the element's attribute & property. This is what lets a parent set state from the outside.useEvent("name", opts)— returns a dispatcher that fires a real DOMCustomEventoff the host. This is how Atomico components talk outward — events, not callbacks-as-props.useHost()— a ref to the actual element, for when you must reach the live DOM.useEffect,useMemo,useRef,useReducer— the React-shaped escape hatches for side-effects, memoisation, and refs.
Why this matters for the comparison
Notice what state is in Atomico: a value hidden in a positional slot, reachable only through a closure the hook handed you. It is invisible, opaque, and tied to render order. Hold that thought — the ClojureScript libraries make the opposite bet, and it is the central contrast of this whole report.
Atomico's communication model, in one picture
Props flow in (attributes & JS properties); events flow out (CustomEvents). Atomico deliberately avoids React-style context, preferring events because that's the native idiom of web components. The diagram traces a single round trip.
§ 4 the pivot
The ClojureScript wager: code is data
Atomico makes the DOM declarative. ClojureScript libraries push the same instinct one layer deeper and make the state machinery declarative too. To see why, you need one idea from Clojure — and it's the idea your whole question rests on.
In most languages, your program's structure (functions, conditionals, handlers) and your program's data (lists, maps, numbers) are different kinds of thing. You can inspect data at runtime; you generally can't inspect a function — it's a black box you can only call.
Clojure blurs that line. Its code is written in the same literal notation as its data: lists in parentheses, vectors in brackets, maps in braces, keywords like :on-click. A keyword is just a value. A vector is just a value. So you can express a great many things that other languages encode as opaque code as transparent data instead:
;; a chunk of UI — a vector, not a function call [:button.primary {:on {:click save!}} "Save"] ;; a side-effect described as data: "dispatch this event with this value" [::dispatch :value :data selected] ;; you can compare, serialise, diff, print, or send any of it (= [:button "A"] [:button "A"]) ;=> true
This is the lever. If a button, an event handler, and a side-effect are all just data, then:
- your view function can be pure — given props, it returns a value and touches nothing;
- you can test a component by calling its view and asserting on the returned data — no DOM, no browser;
- state-management constructs can be compared by value (two handlers that describe the same effect are equal), serialised, and sent over the wire for server-side rendering;
- the framework can be smarter about reconciliation, because it can reason about your handlers instead of treating them as opaque closures.
This is the same hiccup notation Reagent and Re-frame popularised — [:tag attrs & children] — but pointed at web components instead of a React tree. The wager is that "it's just data" buys you more than Atomico's "it's just functions," because data can be inspected and functions can't.
Two libraries answer the wager very differently. zero takes it to its logical end — even side-effects become inert data. baredom takes the strictest possible reading of "pure function of inputs" and removes component-local state entirely. We take them in turn.
§ 5 zero
Zero: views, actions, and bindings as inert data
Zero is the closest ClojureScript analogue to Atomico's ambition — a way to author web components — but it carries the data philosophy all the way down. Here is the same shape of component you saw in Atomico:
(ns example (:require [zero.core :refer [act <<ctx] :as z] [zero.config :as zc])) (defn view [{:keys [count]}] [:root> [:button :#on {:click (act [::inc])} "clicked " count " times"]]) (zc/reg-components :my-counter {:props #{:count} :view view})
The shape rhymes with Atomico — a view function of props, registered as a component, becoming a real custom element in the browser. But three pieces are doing something Atomico can't.
1 · The view returns data, and :root> is the host
The view returns a hiccup vector, not JSX that compiles to function calls. :root> is Zero's exact counterpart to Atomico's <host>: the top node that represents the element itself, where you attach default styles (:#css), host-level event handlers, and lifecycle hooks. The difference is that the entire returned tree is an ordinary value you can println, diff, or assert against in a test.
2 · act — a side-effect that is also a value
This is Zero's signature move and the sharpest contrast with Atomico. In Atomico the click handler is a closure: () => { setCount(count+1); emitChange(); } — opaque, un-serialisable, unequal to any other closure. In Zero the handler is (act [::inc]), which builds an Action: a data-oriented packet that describes the effects to run, without running them.
An action is, quite literally, a vector of effect-vectors plus an optional props map. You register what ::inc does separately, as an effect handler:
;; an effect: the imperative bit, registered once, by key (zc/reg-effects ::dispatch (fn [^js target event-type value] (.dispatchEvent target (js/CustomEvent. (name event-type) #js{:detail value})))) ;; an injection: a placeholder resolved when the action fires (zc/reg-injections ::value (fn [_ ^js target] (.-value target))) ;; the handler is pure data: "dispatch :value carrying the target's value" (act [::dispatch :value (<< ::value <<ctx ::z/current)])
The split is the point. The description of what should happen (the action vector) is inert, comparable, serialisable data. The doing (the effect functions, the imperative DOM calls) is registered once, by key, off to the side. Your view never holds a live function — it holds a recipe.
The << form is an injection: a hole in the data that says “resolve me later.” (<< ::value <<ctx ::z/current) means “at fire-time, read the value off whatever element this handler is attached to.” Because actions get a context map when invoked (the event, its target, the host, the shadow root, the data harvested from the event), you can write fully generic handlers as data and let the context fill in the specifics.
3 · bnd — reactivity as a watchable value
Where Atomico's reactivity lives inside hook lockers, Zero's lives in bindings: dereferenceable, watchable handles onto named “data streams.” You register a stream once; a binding (bnd ::clock) is again just a value you can hand to a prop. The component re-renders when the stream pushes.
(zc/reg-streams ::clock (fn [rx] (let [id (js/setInterval #(rx (js/Date.now)) 1000)] #(js/clearInterval id)))) ; return a cleanup fn ;; bind a stream straight onto a prop — no hook, no slot (zc/reg-components :now {:props {:t (bnd ::clock)} :view (fn [{:keys [t]}] (str t))})
The demo lets you watch the difference that “handlers are data” makes. The same button is shown as an Atomico closure and a Zero action. Click each — and then ask the harder question the panel poses: can you compare two of them for equality?
Two distinct closures are never === even when they do the identical thing — so a framework can't dedupe or memoise them, and you can't serialise one for SSR. Two Zero actions built from the same data are equal. That single property is what “it's just data” buys.
Honest framing, from Zero's own docs
Zero positions actions and bindings as extra tools, not replacements. If an action won't express what you need, use a plain function for the handler; if a binding doesn't fit, use an atom. The data-oriented constructs earn their keep on testability and reconciliation — they aren't a purity mandate.
Zero also leans on the native web-component features Atomico tends to gloss: real slots (with slotted-prop to react to what's plugged in), shadow-DOM stylesheet encapsulation via :#css, and crucially server-side rendering — because the view returns serialisable data and attributes serialise via a Concise Data Format, a component can render to a declarative shadow DOM as an HTML string and rehydrate on the client.
§ 6 baredom
BareDOM: the stateless library at the other pole
BareDOM is the odd one out — and naming why is the key to the whole comparison. Atomico and Zero are frameworks for authoring components. BareDOM is a finished library of components. The distinction changes what “compare” even means.
BareDOM ships ~58 ready-made elements — <x-button>, <x-table>, <x-modal>, <x-color-picker> and so on — each compiled from ClojureScript into a self-contained, framework-free ES module via Google Closure's advanced optimiser. You don't author with BareDOM the way you author with Atomico; you consume its tags from whatever stack you're in, including plain HTML.
Its design rule is a single equation, and it is the strictest reading of “pure function of inputs” in this whole report:
Components are stateless. No atom, no signal, no hook locker, no reactive container lives inside a component. Every visual state is re-derived from attributes and properties at render time. Set an attribute, the DOM updates; remove it, it updates back. Debugging is “inspect the attributes in DevTools” — there is no hidden state to hunt.
Compare the three state stories directly:
Where does state go, then?
It goes up and out. Because the components hold nothing, you keep application state in one place — for ClojureScript consumers, a single atom — and feed it down as hiccup. BareDOM's starter renderer is ~120 lines: it turns hiccup vectors into DOM, listens to your state atom, and on every change reconciles the live DOM in place rather than rebuilding it (so the web components keep their shadow DOM, focus, and animations). You're back to the familiar Reagent-shaped loop — one state atom, a pure view — but rendering to native web components instead of React.
Two CLJS philosophies show up clearly here: state flows in as attributes/props (hiccup keys), and component events flow out as native CustomEvents caught with :on-* keys — the very same in/out model Atomico uses, just expressed in data and with the components pre-built. Theming is pure CSS custom properties (--x-<component>-<property>), so you restyle without touching the component's compiled internals.
The category error to avoid
It's tempting to say “BareDOM vs. Atomico.” But Atomico is what you'd use to build something like BareDOM's <x-button>; BareDOM is what you'd reach for instead of building it. The fair axis is: do you want a toolkit to author components (Atomico, Zero), or a batteries-included set you drop in (BareDOM)?
§ 7 the same problem, three ways
Side by side: the same counter, three ways
Nothing clarifies a comparison like one tiny program written in each idiom. Below: a counter that increments on click and emits a change event. Toggle between the three and read what each line is actually doing.
Same behaviour, three state stories: Atomico keeps it in a hook locker; Zero keeps the view pure and describes the increment as an action; BareDOM keeps nothing — the count lives in your atom and flows back in as an attribute.
§ 8 the reckoning
Compare & contrast: where each one wins
All three compile to the same native substrate, so portability is a wash — any of them works inside React, Vue, vanilla HTML, or on a server-rendered page. The real differences are in authoring model, where state lives, and what role they play. Sort the table by any axis to see the shape of the trade-offs.
No row is “best”. The honest read: Atomico and Zero are authoring tools that happen to disagree about state; BareDOM is a component set that opted out of component state entirely.
The three positions, plainly
- atomico — maximum familiarity. If you know React, you know 80% of it already. State is ergonomic but opaque: hidden in positional hook slots, handlers are un-serialisable closures. Best when you're authoring components and want the React mental model with web-component output.
- zero — maximum transparency. Views, handlers (
act), and reactivity (bnd) are all inert, comparable, serialisable data. That unlocks trivial snapshot testing and true SSR/SSG with rehydration. The cost is a new vocabulary — injections, effects, streams — that has no React equivalent to lean on. - baredom — maximum readiness. You don't author at all; you consume ~58 polished, themeable, accessible, stateless elements. Best when you want a UI kit that survives a framework migration. The trade: you're styling and composing, not defining new behaviour.
So — does CLJS “out-simplify” Atomico?
It depends which simplicity you mean. Atomico is simpler to start: the hook model is borrowed, the JSX is familiar, the conceptual surface is small. Zero is simpler to reason about and test: a pure view returning data, with effects pushed to the edges, is the easier thing to hold in your head once the vocabulary clicks — and the “it's just data” property is real leverage, not a slogan. BareDOM is simplest of all if your need is “give me good components now”, because the simplest component to author is the one you didn't have to.
§ 9 the further question
How much further can “just data” go?
Your intuition — that CLJS is positioned not just to match Atomico but to go further by leaning on “it's just data” — is the right one. Zero is the existence proof. The interesting part is what the data-ness unlocks that a function-and-hook model structurally can't.
- Serialisable behaviour → real SSR. Because a Zero action is data, an event handler can be rendered into server HTML and reconstituted on the client. An Atomico closure cannot survive the trip — you can serialise markup but not behaviour. This is the clearest place “data” beats “functions.”
- Value-equality → cheaper reconciliation & memoisation. If two handlers that mean the same thing are equal, a framework can skip work and dedupe. Opaque closures force conservative re-runs.
- Snapshot testing for free. Call the view, get a value, assert. No render harness, no jsdom. The test is a data comparison.
- Inspectable state machines. When effects, streams, and signals are all keyed data, the whole runtime is introspectable — you can log, diff, and replay it. BareDOM's optional trace tool records every dispatch and attribute change as a navigable cause→effect timeline precisely because nothing is hidden.
There's a ceiling, and it's worth naming. “Just data” only helps where the thing genuinely is describable as data. The moment you need an arbitrary computation in a handler, you either widen your effect vocabulary or drop back to a plain function — which Zero explicitly lets you do. Data-orientation is a powerful default, not a totalising law; the libraries that age well treat it as the former.
If you're choosing
Authoring new components and you love React? Reach for atomico. Authoring components in a CLJS codebase and you want testability and SSR? zero is built for exactly that. Need a polished, framework-agnostic UI kit and don't want to author at all? Drop in baredom — and note you could happily render BareDOM's components from a Zero or Atomico app, since they're all just web components in the end.
That last point is the quiet thesis of this whole report. These three aren't really competitors. They're neighbours on one street — the Web Components platform — each having decided independently that the future of portable UI is a custom tag, and differing only on how much of the machinery behind that tag they're willing to turn into data.