stube is a personal research project, and the fastest way to see what it's actually exploring is to build something small with it. This walkthrough builds standup, a tiny shared todo board: every visitor sees the same list, can post items, edit them in place, delete them with a confirmation, and watch everyone else's edits appear in real time. No JavaScript you have to write (the Datastar runtime is loaded from a CDN and does the morphing), no client/server contract to maintain by hand; at the end of it the whole thing is a single Clojure file.
If you want to know why the framework looks the way it does before you start typing, read the rationale first.
You will meet, in roughly the order you'd reach for them in a real app:
defcomponent — declaring a UI component as data.s/on / s/bind — wiring DOM events and inputs back to the
server.s/call / s/answer — Seaside‑style call/answer for
confirmation dialogs.s/call-in-slot — edit‑in‑place inside a list row.s/subscribe / s/publish! — pushing live updates to every
open browser.s/defflow — sequencing an onboarding wizard as straight‑line
code.You can write the whole thing in src/standup.clj in any new
deps.edn project that depends on stube. The finished file is about
160 lines.
Heads up. The screenshots and CSS in this tutorial are illustrative. The actual app inherits the stock stylesheet at
/stube/ui.css; you can disable it with(s/start! {:ui-css? false})if you prefer to ship your own.
Create a new project:
mkdir standup && cd standup
deps.edn:
{:paths ["src"]
:deps
{dev.zeko/stube {:mvn/version "0.0.1"}}}
Create src/standup.clj and require stube:
(ns standup
(:require [dev.zeko.stube.core :as s]
[clojure.string :as str]))
Open a REPL (clj, cider-jack-in, etc.) and keep it running for
the rest of the tutorial. Every time you re‑evaluate a defcomponent
form, the registry picks up the new definition immediately — no
restart, no rebuild.
Let's start with the smallest thing that puts a row on a page. A single hand‑typed item:
(s/defcomponent :standup/board
:init (constantly {:items []
:draft ""})
:keep #{:draft}
:render (fn [self]
[:section (s/root-attrs self {:class "stube-card"})
[:h1 "Standup"]
[:form (s/on self :submit :as :add)
[:input (merge {:name "draft"
:placeholder "What did you do?"
:value (:draft self)}
(s/bind :draft))]
[:button {:type "submit"} "Post"]]
[:ul
(for [item (:items self)]
[:li {:key (:id item)} (:text item)])]])
:handle (fn [self {:keys [event]}]
(case event
:add
(let [t (str/trim (str (:draft self)))]
(if (str/blank? t)
self
(-> self
(update :items conj {:id (random-uuid)
:text t})
(assoc :draft ""))))
self)))
(s/mount! "/" :standup/board)
(s/start! {:port 8080})
Open http://localhost:8080/ and post a few items. They appear, the
form clears, the page never reloads. Everything you typed in the
input was kept on the server thanks to :keep #{:draft} and
(s/bind :draft) — that's the entire two‑way binding story.
Some things worth noting:
:init returns the initial state of an instance of this
component. Pass in whatever; the example takes no args.
:render returns hiccup. The root element gets its instance id
via s/root-attrs — that id is what Datastar morphs against on the
next patch. Without it, you'd be playing whack‑a‑mole with DOM
identity.
s/on wires a DOM event back to the server. :submit is
the DOM event we listen for; :as :add is the logical name your
:handle sees in :event.
:handle is (fn [self event] …). It can return:
[self' effects] → bothnil → no changeWe return just the new self in this example.
The browser never sees JavaScript. Datastar reads the data‑* attributes the server emitted and turns them into POSTs. Your handler returns a new value; the kernel re‑renders the component; Datastar morphs the difference into the DOM.
Right now items are forever. Let's add a delete button — and put a confirmation in front of it so accidental clicks don't nuke yesterday's standup. This is where stube's flagship primitive appears.
Update the row in :render:
[:ul
(for [item (:items self)]
[:li {:key (:id item)
:style "display:flex; gap:0.5rem;"}
[:span {:style "flex:1;"} (:text item)]
[:button (s/on self :click :as [:delete (:id item)])
"✕"]])]
Notice :as [:delete (:id item)]. That's a structured event:
the handler will receive {:event :delete :payload <id>}. Anything
that needs to ride along with the event goes in the payload; no
hidden state, no signal hacks.
Then handle it:
:handle
(fn [self {:keys [event payload]}]
(case event
:add (let [t (str/trim (str (:draft self)))]
(if (str/blank? t)
self
(-> self
(update :items conj {:id (random-uuid) :text t})
(assoc :draft ""))))
:delete [(s/call :ui/confirm {:question "Delete this item?"}
:on-confirm-delete)]
self))
:on-confirm-delete
(fn [self yes?]
(if yes?
;; TODO: which item did we want to delete, again?
self
self))
You can see this works — clicking ✕ pops up a Yes/No card. But the TODO is real: by the time the confirmation answers, we've forgotten which item the user wanted to delete. We need to remember it.
The cleanest way is to put the pending id on self between the
question and the answer:
:delete [(assoc self :pending-delete payload)
[(s/call :ui/confirm {:question "Delete this item?"}
:on-confirm-delete)]]
…and read it back in the resume key:
:on-confirm-delete
(fn [self yes?]
(let [id (:pending-delete self)
self' (dissoc self :pending-delete)]
(if (and yes? id)
(update self' :items #(into [] (remove (fn [x] (= (:id x) id))) %))
self')))
What just happened, at the kernel level:
:event :delete, :payload <id>.:handle returned [self' [(s/call ...)]] — update self,
then emit one effect: push a child onto the stack.:ui/confirm (one of stube's stock
components), recorded that when it :answers the parent's
:on-confirm-delete should fire, and rendered the new top
frame over your list.:ui/confirm emitted
[:answer true] or [:answer false].:on-confirm-delete on
the parent, and invoked it with the answered value.This is the primitive: a component calls another component, gets the answer, and continues. No callback hell, no client‑side modal state, no risk of the user clicking ✕ on a different row in the meantime — the parent never lost control of the page.
Aside.
s/confirm,s/prompt,s/chooseands/infoare stock components shipped with stube. You can build your own — any component that emits[:answer v]is a valid callable — and we will, in §4.
Click an item to edit it; submit to save; cancel to back out. We want exactly one row at a time to be in edit mode, and we want the rest of the page to keep working while it's open.
This is what s/call-in-slot is for. A normal s/call puts the
child on top of the page, hiding the parent. call-in-slot puts
the child into a specific slot of the parent's render, leaving
the rest of the page alone. The child still :answers back to the
parent; the parent decides what answer means.
Define a small editor component:
(s/defcomponent :standup/editor
:init (fn [{:keys [id text]}]
{:id id :text text})
:keep #{:text}
:render (fn [self]
[:form (s/root-attrs self
{:style "display:flex; gap:0.5rem;"}
(s/on self :submit))
[:input (merge {:name "text"
:value (:text self)
:autofocus true}
(s/local-bind self :text))]
[:button {:type "submit"} "Save"]
[:button (merge {:type "button"}
(s/on self :click :as :cancel))
"Cancel"]])
:handle (fn [self {:keys [event]}]
[(s/answer (if (= event :cancel)
s/cancel
{:id (:id self) :text (:text self)}))]))
Two new things:
s/local-bind instead of s/bind. Datastar signals are
page‑global: if two <input>s on the same page both bind
:text, they share a value. That would mean the editor and the
parent's :draft could collide. local-bind suffixes the wire
key with the instance id, so each editor instance gets its own
signal while you still read (:text self) in code.s/cancel is a sentinel value provided by stube. The
convention: cancellable components :answer it when the user
bails.Update the row render to switch between display and edit modes:
[:ul
(for [item (:items self)]
[:li {:key (:id item)
:style "display:flex; gap:0.5rem;"}
(if (= (:editing-id self) (:id item))
;; This row is being edited: render the editor in its slot.
[:span {:style "flex:1;"}
(s/render-slot self :slot/editor)]
;; Normal display row: clicking the text opens the editor.
[:span (merge {:style "flex:1; cursor:text;"}
(s/on self :click :as [:edit (:id item)]))
(:text item)])
[:button (s/on self :click :as [:delete (:id item)]) "✕"]])]
And in :handle:
:edit
(let [item (some #(when (= (:id %) payload) %) (:items self))]
(when item
[(assoc self :editing-id (:id item))
[(s/call-in-slot :slot/editor
:standup/editor {:id (:id item)
:text (:text item)}
:on-edit)]]))
call-in-slot takes a slot key (yours to invent), the embed spec
of the child, and the resume key the parent listens on. When the
editor answers, the parent's :on-edit fires:
:on-edit
(fn [self answer]
(let [self' (assoc self :editing-id nil)]
(if (= answer s/cancel)
self'
(let [{:keys [id text]} answer
t (str/trim (str text))]
(if (str/blank? t)
self'
(update self' :items
(fn [xs]
(mapv #(if (= (:id %) id)
(assoc % :text t) %) xs))))))))
Reload, click an item, edit it, Save. Click again, Cancel. Each edit only re‑renders that one row.
Open / in two tabs. They don't see each other. Time to fix that.
stube's pub/sub is just two effects: (s/subscribe topic event) and
the function (s/publish! topic msg). Subscribers get the published
message delivered as a regular event with :payload set to the
msg, so handlers handle network events exactly like clicks.
We'll keep the shared state outside the conversation — every visitor's conversation starts by reading the current world and then subscribing for changes.
;; Process-global: the standup itself.
(defonce ^:private !world (atom {:items []}))
(def ^:private topic :standup/changed)
(defn- publish-world! []
(s/publish! topic @!world))
(defn- update-world! [f]
(let [w (swap! !world f)]
(s/publish! topic w)))
Then wire it into the component:
:init (fn [_] (merge {:draft "" :editing-id nil :pending-delete nil}
@!world))
:start (fn [_self]
[(s/subscribe topic :world-changed)])
:wakeup (fn [self]
[(merge self @!world)
[(s/subscribe topic :world-changed)]])
:stop (fn [_self]
[(s/unsubscribe topic)])
:start runs once when the component is first instantiated:
here we sign up for the topic.:wakeup runs when a conversation is restored from history
or persistence: re‑read the world and re‑subscribe.:stop runs when the frame leaves: unsubscribe so we don't
leak.Handle the topic delivery the same way you'd handle a click:
:world-changed
(fn [self world]
(merge self world))
Replace each local mutation with an update-world!:
:add
(let [t (str/trim (str (:draft self)))]
(if (str/blank? t)
self
(do (update-world! #(update % :items conj
{:id (random-uuid) :text t}))
(assoc self :draft ""))))
:on-confirm-delete
(fn [self yes?]
(let [id (:pending-delete self)
self' (dissoc self :pending-delete)]
(when (and yes? id)
(update-world!
#(update % :items
(fn [xs] (into [] (remove (fn [x] (= (:id x) id))) xs)))))
self'))
:on-edit
(fn [self answer]
(let [self' (assoc self :editing-id nil)]
(if (= answer s/cancel)
self'
(let [{:keys [id text]} answer
t (str/trim (str text))]
(when-not (str/blank? t)
(update-world!
#(update % :items
(fn [xs] (mapv (fn [x]
(if (= (:id x) id)
(assoc x :text t) x)) xs)))))
self'))))
Open two tabs. Type in one. The other updates instantly. Edit in one; the other follows. Delete; both vanish. That is the entire live‑update story: two effects and one resume key.
Why publish from inside
:handleand also updateself? The publish goes out to every subscriber, including the current conversation — and yours will receive its own:world-changedevent a moment later. Returning the updatedselfimmediately just means the user posting the message doesn't see a frame of latency before their own message appears.
defflowRight now anyone can post anonymously. Let's gate the page behind a two‑step intro: ask the user's name, confirm it's right, then drop them on the board.
You could write this as a parent task component with :start and
resume keys, but stube has a sweeter shape for linear flows:
(s/defflow :standup/onboard []
(loop []
(let [name (s/await (s/prompt "Who's standing up?"))]
(if (= name s/cancel)
(recur) ; refuse to proceed without a name
(let [ok? (s/await (s/confirm (str "Welcome, " name "! Begin?")))]
(if ok?
(s/await (s/embed :standup/board {:user name}))
(recur)))))))
(s/mount! "/" :standup/onboard)
That's literally what runs. defflow compiles the body into a
component whose state is a cloroutine continuation;
(s/await child) is the suspend point. Between awaits is ordinary
Clojure — let, if, recur, side effects, you name it.
A few rules of the road:
await cannot appear inside a nested (fn …) or a lazy seq.
let, if, cond, when, loop/recur, do are all fine.:answer. As the
root flow, that turns into :end and closes the SSE stream.defflow continuations are not EDN, so the
file‑backed store skips them. Use defcomponent task components
with explicit resume keys when you need EDN persistence.Take :user into the board's :init so the user's name flows
through:
:init (fn [{:keys [user]}]
(merge {:user user
:draft ""
:editing-id nil
:pending-delete nil}
@!world))
…and stamp each item with the author:
{:id (random-uuid) :text t :by (:user self)}
Reload. You'll be greeted by a Datastar‑rendered modal asking your
name, then a confirmation, then the standup. Refresh: the flow
restarts because it's the root flow, but the world (which lives in
!world) persists across refreshes.
You now have a real Clojure app, server‑rendered, live‑updating, zero JavaScript, ~160 lines. Real applications stitch the same primitives together for everything they do.
A few next steps worth exploring:
(s/back-button "Back") somewhere and watch
the kernel rewind. See examples/dev/zeko/stube/examples/wizard.clj
for the pattern.s/after for timers. Refresh "X seconds ago" labels by
emitting (s/after 30000 :tick) from :start, and re‑emitting
it on :tick.s/upload-attrs for zero‑JS file uploads. See
examples/dev/zeko/stube/examples/file_upload.clj.s/decorate!.(s/inspect cid) shows the live conversation;
(s/tree cid) prints the component tree; (s/replay :standup/board [{:event :add :signals {:draft "test"}}]) walks the same code
path the browser does, with no server running.Read the API reference for everything in
dev.zeko.stube.core, and the internals for
how the kernel makes all of this go.
The complete src/standup.clj is below for copy‑paste convenience.
(ns standup
(:require [dev.zeko.stube.core :as s]
[clojure.string :as str]))
(defonce ^:private !world (atom {:items []}))
(def ^:private topic :standup/changed)
(defn- update-world! [f]
(let [w (swap! !world f)]
(s/publish! topic w)))
(s/defcomponent :standup/editor
:init (fn [{:keys [id text]}] {:id id :text text})
:keep #{:text}
:render (fn [self]
[:form (s/root-attrs self
{:style "display:flex; gap:0.5rem;"}
(s/on self :submit))
[:input (merge {:name "text" :value (:text self) :autofocus true}
(s/local-bind self :text))]
[:button {:type "submit"} "Save"]
[:button (merge {:type "button"} (s/on self :click :as :cancel))
"Cancel"]])
:handle (fn [self {:keys [event]}]
[(s/answer (if (= event :cancel)
s/cancel
{:id (:id self) :text (:text self)}))]))
(s/defcomponent :standup/board
:init (fn [{:keys [user]}]
(merge {:user user
:draft ""
:editing-id nil
:pending-delete nil}
@!world))
:keep #{:draft}
:start (fn [_self] [(s/subscribe topic :world-changed)])
:wakeup (fn [self] [(merge self @!world)
[(s/subscribe topic :world-changed)]])
:stop (fn [_self] [(s/unsubscribe topic)])
:render
(fn [self]
[:section (s/root-attrs self {:class "stube-card"})
[:h1 (str "Standup — " (:user self))]
[:form (s/on self :submit :as :add)
[:input (merge {:name "draft" :placeholder "What did you do?"
:value (:draft self)}
(s/bind :draft))]
[:button {:type "submit"} "Post"]]
[:ul
(for [item (:items self)]
[:li {:key (:id item) :style "display:flex; gap:0.5rem;"}
(if (= (:editing-id self) (:id item))
[:span {:style "flex:1;"} (s/render-slot self :slot/editor)]
[:span (merge {:style "flex:1; cursor:text;"}
(s/on self :click :as [:edit (:id item)]))
(:text item)
[:small {:style "color:#888;"} " — " (:by item)]])
[:button (s/on self :click :as [:delete (:id item)]) "✕"]])]])
:handle
(fn [self {:keys [event payload]}]
(case event
:add
(let [t (str/trim (str (:draft self)))]
(if (str/blank? t)
self
(do (update-world!
#(update % :items conj
{:id (random-uuid) :text t :by (:user self)}))
(assoc self :draft ""))))
:delete
[(assoc self :pending-delete payload)
[(s/call :ui/confirm {:question "Delete this item?"} :on-confirm-delete)]]
:edit
(when-let [item (some #(when (= (:id %) payload) %) (:items self))]
[(assoc self :editing-id (:id item))
[(s/call-in-slot :slot/editor
:standup/editor {:id (:id item) :text (:text item)}
:on-edit)]])
self))
:on-confirm-delete
(fn [self yes?]
(let [id (:pending-delete self)
self' (dissoc self :pending-delete)]
(when (and yes? id)
(update-world!
#(update % :items
(fn [xs] (into [] (remove (fn [x] (= (:id x) id))) xs)))))
self'))
:on-edit
(fn [self answer]
(let [self' (assoc self :editing-id nil)]
(if (= answer s/cancel)
self'
(let [{:keys [id text]} answer
t (str/trim (str text))]
(when-not (str/blank? t)
(update-world!
#(update % :items
(fn [xs]
(mapv (fn [x] (if (= (:id x) id)
(assoc x :text t) x)) xs)))))
self'))))
:world-changed
(fn [self world]
(merge self world)))
(s/defflow :standup/onboard []
(loop []
(let [name (s/await (s/prompt "Who's standing up?"))]
(if (= name s/cancel)
(recur)
(let [ok? (s/await (s/confirm (str "Welcome, " name "! Begin?")))]
(if ok?
(s/await (s/embed :standup/board {:user name}))
(recur)))))))
(s/mount! "/" :standup/onboard)
(defn -main [& _]
(s/start! {:port 8080})
@(promise))
Run it:
clojure -M -m standup
Open http://localhost:8080/, and you're done. Open it in a second tab to watch the live updates fly. ✦
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |