A Seaside-inspired Clojure web framework where UI flows read as linear code, resumed across HTTP via virtual threads and pushed to the browser over Datastar SSE.
Status: draft v0.1 — pre-prototype. Open questions are flagged ⚠.
Most Clojure web stacks treat each HTTP request as an isolated event, leaving the developer to thread application state through routes, handlers, and templates. Seaside (Smalltalk) demonstrated a radically different model:
A user-facing workflow is one function. When it needs input from the user, it calls a component the way you would call any other function. The user clicks; the function returns; execution continues.
In Smalltalk this is implemented with first-class continuations. In Clojure we have three plausible substitutes:
Promise between user interactions.cloroutine, custom CPS transform) — the
conversation is a serialisable state machine.stube chooses option 1 for v1. The user-facing API is designed so that
option 2 can be slotted in later as an alternative kernel without changing
flow code.
The transport is Datastar (long-lived SSE + signal-driven events). Datastar maps onto the framework's natural lifecycle — one channel per conversation, events resume the parked thread, fragments are pushed back as patches — without any impedance mismatch.
A flow is a Clojure function. It runs top to bottom. When it needs the user
to do something it calls ask with a component; ask blocks until the user
submits, then returns the value the component produced. show pushes a
fragment to the page without blocking. answer is how a component, from
inside its event handler, returns a value to the ask that produced it.
(s/defflow calc []
(let [a (s/ask (ui/number-input "First number"))
b (s/ask (ui/number-input "Second number"))]
(s/show [:div.result "Sum: " (+ a b)])))
That is the whole "Holy Grail" of web programming: one expression, two human interactions, no callback inversion, real stack frames.
Composition works the same way. A component's event handler may itself ask
a sub-component:
(s/defcomponent confirm-delete [item]
:render (fn [_]
[:div [:p "Delete " item "?"]
[:button (s/on :click ::go) "Delete"]])
:on
{::go (fn [_ _]
(let [reason (s/ask (ui/text-input "Why?"))]
(s/answer {:confirmed? true :reason reason})))})
That is Seaside's call:/answer: protocol, in Clojure, with no objects.
A function written with defflow. Started by an HTTP route; runs on a virtual
thread; lives until it returns, throws, or is reaped.
(s/defflow checkout [cart] ...)
(s/mount! "/checkout" #'checkout)
A flow is not a component. It produces no markup of its own; everything the
user sees comes from ask/show calls inside it.
A value (a map) describing a chunk of UI plus its event handlers and per-
instance state. Produced by defcomponent. Components are pure data so they
can be composed, memoised, and inspected.
{:stube/component :ui/number-input
:stube/instance-id "ni-7e2"
:stube/state {:value ""}
:stube/render (fn [self] ...)
:stube/handlers {::submitted (fn [self event] ...)}}
A live execution of a flow, bound to one browser session. Owns:
ask (component + promise) — if any,A session may host many simultaneous conversations (e.g. one per browser tab). Each conversation gets its own SSE channel.
╭─────────────────────────────────────────────────────────────────╮
│ Browser │
│ │
│ ╭─────────────╮ ╭───────────────────────────╮ │
│ │ Datastar │◀───SSE──│ conversation event feed │ │
│ │ client │ ╰───────────────────────────╯ │
│ │ (signals, │ │
│ │ patches) │─POST /flow/:cid/event ─────────╮ │
│ ╰─────────────╯ │ │
╰──────────────────────────────────────────────────┼──────────────╯
▼
╭─────────────────────────────────────────────────────────────────╮
│ Server │
│ │
│ ╭───────────╮ ╭──────────────────────────────────────╮ │
│ │ Reitit │───▶│ Conversation registry (atom map) │ │
│ │ routes │ │ cid → conversation │ │
│ ╰───────────╯ ╰──────────────────────────────────────╯ │
│ │ │ │
│ │ /start │ lookup │
│ ▼ ▼ │
│ ╭───────────────────────────────────────────────────────╮ │
│ │ Conversation │ │
│ │ ╭───────────────╮ ╭─────────────────────────────╮ │ │
│ │ │ Virtual thread│ │ SSE handle (Datastar) │ │ │
│ │ │ running flow │──▶│ patch-elements!, ... │ │ │
│ │ ╰──────┬────────╯ ╰─────────────────────────────╯ │ │
│ │ │ ask → parks on Promise │ │
│ │ │ answer → delivers Promise │ │
│ │ ╭──────▼────────────────────────────────────────╮ │ │
│ │ │ Pending ask: {component-id, promise, comp} │ │ │
│ │ ╰────────────────────────────────────────────────╯ │ │
│ ╰───────────────────────────────────────────────────────╯ │
╰─────────────────────────────────────────────────────────────────╯
Browser Router Conversation Vthread (flow)
│ │ │ │
│ GET /calc │ │ │
├────────────────▶│ start-flow! │ │
│ ├─────────────────▶│ spawn vthread │
│ │ ├───────────────────▶│
│ │ │ │ run body
│ ◀──── HTML shell + open SSE ◀──────┤ │ ask(c1)
│ │ │◀───── park ────────┤
│ ◀── SSE: patch │ │ │
│ │ │ │
│ POST /event │ │ │
│ {cid, c1.submit}│ │ │
├────────────────▶│ dispatch │ │
│ ├─────────────────▶│ run handler │
│ │ │ → answer(42) │
│ │ │ deliver promise ──▶│
│ │ │ │ resume
│ │ │ │ ask(c2)
│ ◀── SSE: patch │ │◀───── park ────────┤
│ ... │ │ │
(def ^:dynamic *conversation* nil)
(defn ask [component]
(let [conv *conversation*
cid (:id component)
p (promise)]
(swap! conv assoc :pending {:component-id cid :promise p :component component})
(push-fragment! (:sse @conv) (render component))
@p)) ; vthread parks here
(defn show [hiccup]
(push-fragment! (:sse @*conversation*) hiccup))
(defn answer [value]
(let [{:keys [pending]} @*conversation*]
(swap! *conversation* dissoc :pending)
(deliver (:promise pending) value)))
(defn start-flow! [flow-fn args session-id sse-handle]
(let [conv (atom {:sse sse-handle :session session-id :touched (now)})]
(.start (Thread/ofVirtual)
#(binding [*conversation* conv]
(try (apply flow-fn args)
(finally (close-conversation! conv)))))
conv))
| concern | virtual threads | cloroutine / CPS |
|---|---|---|
| stack traces | real, full | rewritten, partial |
| debugger support | works | poor |
| forms allowed in flow | any Clojure | restricted (no try across yields, etc.) |
| serialisable state | no | yes |
| survives restart | no | yes |
| cost per conversation | ~1 KB stack initially | one closure |
For a v1 framework whose target user runs one or two app servers and accepts
"server restart drops in-flight conversations," vthreads win on every axis
that affects daily development. The CPS path is left as a future alternative
kernel — the ask/show/answer API does not need to change to support
it.
defcomponent(s/defcomponent number-input
[prompt]
:state {:value ""}
:render (fn [self]
[:form (s/on :submit ::submit)
[:label prompt]
[:input (s/bind self :value)]
[:button "OK"]])
:on
{::submit (fn [self _]
(s/answer (parse-long (:value self))))})
Expands to a function that returns a fresh component map each time it is called. The map is the contract — the macro is a convenience.
Every component instance produced by an ask gets a stable instance-id
(short ULID). This id is:
id of its root element (so Datastar patches replace it cleanly),POST /event payloads carry it),Component state is held in the conversation, keyed by instance-id. Handlers
receive the current self (state merged with metadata) and an event (the
Datastar signal payload + event name). They mutate via:
(s/update-state self f & args) — replace state in the registry, re-render,
push a patch.(s/answer self v) — fulfil the parking ask with v.This explicitness avoids the Seaside ambiguity where returning a value from a
handler and calling answer: both did similar things.
A handler may call ask on another component. Because handlers run on the
flow's vthread (we route the event onto it — see §6.4), nested asks work the
same as top-level ones.
{:components
{"ni-7e2" {:def number-input :state {:value "42"} :handlers {...}}
"wz-001" {:def wizard :state {:step 2} :handlers {...}}}}
Cleared per-component on answer, fully cleared on conversation end.
A stube conversation needs:
@post / @get actions.All three exist in Datastar as first-class primitives. Building on top of it is "use the protocol", not "graft something onto it."
| stube concept | Datastar mechanism |
|---|---|
| Open a conversation | GET /flow/:name/start returns shell HTML that opens an SSE to /flow/:cid/sse |
show / re-render | patch-elements! on the conversation's SSE |
ask blocks | (kernel-side) — Datastar doesn't know; it just stops receiving patches |
| Component event | data-on:submit="@post('/flow/:cid/event?c=<iid>&e=<evt>')" |
| Two-way input bind | data-bind:value="$<scoped-signal>" reading & writing into a per-instance signal |
| Final result page | A last patch-elements! then close-stream! |
A single static HTML shell loads the Datastar JS bundle and opens the SSE. All subsequent UI comes from patches; no full page reload across the conversation's lifetime (except the user manually reloading, which resumes — see §8.2).
(defn handle-event [{:keys [params session]}]
(let [{:keys [cid c e]} params
signals (read-signals params)
conv (lookup-conv cid)
component (get-in @conv [:components c])
handler (get-in component [:handlers (keyword e)])]
(run-on-flow-thread! conv
#(handler (assoc component :state (latest-state conv c) :signals signals)
{:event (keyword e) :signals signals}))
(sse-empty-200)))
Two design notes:
ask to work — the handler is part of the flow.Signals are namespaced per component instance to avoid collisions:
$cmp.ni-7e2.value ; bound to number-input #ni-7e2's :value
$cmp.wz-001.step ; wizard internal
(s/bind self :value) expands to {:data-bind:value "$cmp.<iid>.value"} and
the event payload deserialises that signal back into the component's state.
| route | method | purpose |
|---|---|---|
/flow/:name/start | GET | Mint cid, render shell, open SSE |
/flow/:cid/sse | GET | (Re)attach SSE — internal |
/flow/:cid/event | POST | Dispatch component event onto flow vthread |
/flow/:cid/end | POST | Cancel a conversation explicitly (optional) |
All /event routes require CSRF (anti-forgery). Conversation ids are random
and unguessable; ownership is double-checked against the session id.
GET /flow/calc/start
│
▼
╭──────────────────────────────────────╮
│ 1. mint cid │
│ 2. allocate conversation atom │
│ 3. render shell HTML with cid baked │
│ in (and SSE connect URL) │
│ 4. spawn vthread → calc-flow │
│ (it parks on first ask) │
╰──────────────────────────────────────╯
│
▼ HTML shell to browser
│ browser opens SSE → /flow/:cid/sse
▼
╭──────────────────────────────────────╮
│ 5. attach SSE handle to conversation │
│ 6. flush queued fragments produced │
│ by the parked first ask │
╰──────────────────────────────────────╯
The conversation is still alive on the server. The browser opens a new SSE,
the server reattaches, and re-pushes the latest frame (the rendering of
the currently-pending ask, plus any latest show content). The flow does not
restart.
This is what makes refresh "do the right thing." It is not the back button — that requires history (see §11).
on SSE disconnect → start 30s grace timer
on reconnect within grace → cancel timer
on grace expiry → reap (interrupt vthread, drop conversation)
idle conversation (no event, no SSE) > 30 minutes → reap
flow returns or throws → reap
⚠ Tunables. The numbers above are sensible defaults, not law.
A flow that throws is reaped. The browser receives a "this conversation ended" patch with a button to start a new one. Stack traces are logged server-side with the cid.
All conversations die. The shell on the browser detects SSE close + fail-to- reconnect and shows "the server restarted; please refresh." This is a stated limitation of v1. (See §11 for the persistence track.)
;; Definition
(s/defflow name [params*] body*)
(s/defcomponent name [params*] :state m :render f :on m)
;; Mounting
(s/mount! path #'flow-var)
;; Inside a flow or handler
(s/ask component) ; → user-supplied value
(s/show hiccup) ; → nil, pushes fragment
(s/answer value) ; → never returns from handler's POV
(s/update-state self f & args) ; mutate component state, re-render
;; Hiccup helpers
(s/on event-kw handler-kw & opts) ; → data-on:event="@post(...)"
(s/bind self attr) ; → data-bind:attr="$cmp.<iid>.<attr>"
(s/scoped-signal self k) ; → "$cmp.<iid>.<k>"
;; Introspection / ops
(s/active-conversations)
(s/end! cid)
(s/defflow signup []
(let [email (s/ask (ui/text-input "Email"))
plan (s/ask (ui/select "Plan" [:free :pro :enterprise]))]
(case plan
:free (s/show (ui/welcome email))
:pro (let [card (s/ask (ui/card-form))]
(charge! card 19)
(s/show (ui/welcome email)))
:enterprise (let [contact (s/ask (ui/text-input "Phone"))]
(notify-sales! email contact)
(s/show (ui/will-be-in-touch))))))
What's notable:
card-form may itself ask — e.g. for an OTP — without any change here.let line.Compare to the same logic written with explicit handlers and stored state machines: 5–10× the code, every transition is a named function.
| feature | Seaside | stube | HTMX/handlers | Electric |
|---|---|---|---|---|
| linear flow code | yes (continuations) | yes (vthreads) | no | yes (reactive) |
| component composition | objects | values | partials | functions |
| persistent state | image | session-only | URL/db | reactive db |
| back button | works (continuations) | no (v1) | works (stateless) | n/a |
| restart resilience | image | none (v1) | full | depends |
| transport | full pages | Datastar SSE patches | hx-* attrs | proprietary |
defflow macro (capture body into a fn, register).*conversation* dynamic var.ask / show / answer against in-memory state.patch-elements! wrapper./flow/:name/start, /flow/:cid/event.Defer: defcomponent macro (write components by hand as maps), reaper,
sub-component nesting, back button.
defcomponent macro.s/bind, scoped names, signal read on event).s/on, s/update-state.(active-conversations), (end! cid), basic logging.run-flow-headless driving asks programmatically).stube is a placeholder. Alternatives: parlor, salon,
dialog, seance.(def calc (s/flow [] ...))? Probably just symmetry with defcomponent.show return anything? Currently nil. Could return the
rendered hiccup for testing convenience./calc twice in two tabs, do
they share a conversation or each get their own? Default: each tab gets
its own (cid is per-start, not per-session).End of design doc.
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 |