Liking cljdoc? Tell your friends :D

stube — design document

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: historical draft v0.1 — pre-prototype. Open questions are flagged ⚠. Current user-facing docs are api.md, tutorial.md, and internals.md.


1. Motivation

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:

  1. Virtual threads + promises — the conversation is a real JVM thread that parks on a Promise between user interactions.
  2. CPS / coroutine macros (cloroutine, custom CPS transform) — the conversation is a serialisable state machine.
  3. Reactive graphs (Hyperfiddle Electric) — a different paradigm altogether.

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.


2. The user's mental model

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.


3. The three core concepts

3.1 Flow

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.

3.2 Component

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] ...)}}

3.3 Conversation

A live execution of a flow, bound to one browser session. Owns:

  • the virtual thread running the flow,
  • the SSE handle pushing patches to the browser,
  • the single pending ask (component + promise) — if any,
  • a registry of mounted components (so events route to the right handler),
  • bookkeeping (last-touched timestamp, flow name, started-at).

A session may host many simultaneous conversations (e.g. one per browser tab). Each conversation gets its own SSE channel.


4. Architecture

4.1 High-level

╭─────────────────────────────────────────────────────────────────╮
│                          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}   │    │     │
│   │  ╰────────────────────────────────────────────────╯    │     │
│   ╰───────────────────────────────────────────────────────╯     │
╰─────────────────────────────────────────────────────────────────╯

4.2 The flow lifecycle (sequence)

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 ────────┤
   │ ...             │                  │                    │

4.3 The kernel (pseudocode)

(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))

4.4 Why virtual threads, not core.async / cloroutine

concernvirtual threadscloroutine / CPS
stack tracesreal, fullrewritten, partial
debugger supportworkspoor
forms allowed in flowany Clojurerestricted (no try across yields, etc.)
serialisable statenoyes
survives restartnoyes
cost per conversation~1 KB stack initiallyone 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.


5. Components

5.1 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.

5.2 Per-instance identity

Every component instance produced by an ask gets a stable instance-id (short ULID). This id is:

  • the DOM id of its root element (so Datastar patches replace it cleanly),
  • the routing key for events (POST /event payloads carry it),
  • the lookup key in the conversation's component registry.

5.3 State

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.
  • (return value is ignored.)

This explicitness avoids the Seaside ambiguity where returning a value from a handler and calling answer: both did similar things.

5.4 Composition

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.

5.5 The component registry

{: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.


6. Datastar integration

6.1 Why Datastar specifically

A stube conversation needs:

  1. A persistent server-to-browser channel to push fragments mid-flow without the user navigating. → Datastar SSE.
  2. A way for the browser to send events without reloading the page. → Datastar @post / @get actions.
  3. Two-way bound input state so handlers receive what the user typed. → Datastar signals.

All three exist in Datastar as first-class primitives. Building on top of it is "use the protocol", not "graft something onto it."

6.2 Mapping

stube conceptDatastar mechanism
Open a conversationGET /flow/:name/start returns shell HTML that opens an SSE to /flow/:cid/sse
show / re-renderpatch-elements! on the conversation's SSE
ask blocks(kernel-side) — Datastar doesn't know; it just stops receiving patches
Component eventdata-on:submit="@post('/flow/:cid/event?c=<iid>&e=<evt>')"
Two-way input binddata-bind:value="$<scoped-signal>" reading & writing into a per-instance signal
Final result pageA last patch-elements! then close-stream!

6.3 The shell

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).

6.4 Event dispatch

(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:

  • Handlers run on the flow's vthread, not on the HTTP worker. The HTTP worker just enqueues the call and returns immediately. This is what allows nested ask to work — the handler is part of the flow.
  • One handler at a time per conversation. Events that arrive while a handler is running are queued. This is the right default — Seaside makes the same choice — and removes a class of races.

6.5 Signals

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.


7. HTTP surface

routemethodpurpose
/flow/:name/startGETMint cid, render shell, open SSE
/flow/:cid/sseGET(Re)attach SSE — internal
/flow/:cid/eventPOSTDispatch component event onto flow vthread
/flow/:cid/endPOSTCancel 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.


8. Lifecycle & operations

8.1 Start

  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           │
  ╰──────────────────────────────────────╯

8.2 Reconnect (refresh / network blip)

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).

8.3 Reaper

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.

8.4 Errors

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.

8.5 Server restart

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.)


9. Public API

;; 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)

10. Worked example: branching wizard

(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:

  • The branch is plain Clojure. No state machine, no router config.
  • card-form may itself ask — e.g. for an OTP — without any change here.
  • Adding a step is one 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.


11. Out of scope for v1 (explicit non-goals)

  • Back-button time travel. Vthreads can't rewind. If a flow has 5 asks and the user hits Back, they currently re-attach to ask #5. Real fix is the CPS kernel + per-step checkpointing.
  • Persistence across server restart. Same fix.
  • Multi-server failover. Conversation lives on one node. A sticky session / consistent-hash router is the workaround; true mobility needs serialisable state.
  • Parallel asks (one flow showing two waiting components at once). The pending-ask map is a single slot. Useful, not v1.
  • Authoring components in CLJS. Components are server-side; signals + SSE fragments are the only client-side machinery. No isomorphic story.

12. Comparison

featureSeasidestubeHTMX/handlersElectric
linear flow codeyes (continuations)yes (vthreads)noyes (reactive)
component compositionobjectsvaluespartialsfunctions
persistent stateimagesession-onlyURL/dbreactive db
back buttonworks (continuations)no (v1)works (stateless)n/a
restart resilienceimagenone (v1)fulldepends
transportfull pagesDatastar SSE patcheshx-* attrsproprietary

13. Implementation plan

Slice 1 — vertical kernel (1 week)

  • defflow macro (capture body into a fn, register).
  • Conversation registry (atom of cid→conversation atom).
  • Vthread spawn + *conversation* dynamic var.
  • ask / show / answer against in-memory state.
  • Datastar adapter: SSE handle, patch-elements! wrapper.
  • Two routes: /flow/:name/start, /flow/:cid/event.
  • One demo: the calculator. End-to-end, in browser.

Defer: defcomponent macro (write components by hand as maps), reaper, sub-component nesting, back button.

Slice 2 — components (1 week)

  • defcomponent macro.
  • Component registry per conversation.
  • Signal binding (s/bind, scoped names, signal read on event).
  • s/on, s/update-state.
  • Demo 2: signup wizard with branching.

Slice 3 — robustness (1 week)

  • Reaper (SSE-disconnect grace, idle timeout).
  • Reconnect / re-attach (latest-frame replay).
  • Error → friendly end-of-conversation patch.
  • CSRF wired through.
  • Ops: (active-conversations), (end! cid), basic logging.

Slice 4 — DX (open-ended)

  • Better error pages (interactive REPL into a paused flow?).
  • Test harness (run-flow-headless driving asks programmatically).
  • Devtools panel (list conversations, peek state, force-end).

14. Open questions

  1. Naming. stube is a placeholder. Alternatives: parlor, salon, dialog, seance.
  2. Macro vs fn for flows. Is the macro buying anything beyond (def calc (s/flow [] ...))? Probably just symmetry with defcomponent.
  3. Should show return anything? Currently nil. Could return the rendered hiccup for testing convenience.
  4. Reaper defaults. 30s grace, 30min idle — needs validation against a real app.
  5. Multi-tab. If the same session opens /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).
  6. Error in a handler. Does it kill the conversation or just the handler? Default: kill (matches Seaside). Could soften to "handler error, stay alive, surface a banner."
  7. Anti-forgery on the SSE GET. Standard Ring anti-forgery skips GETs; that's fine, but we should think about whether the cid alone is enough capability.

15. Glossary

  • Flow — top-level Clojure fn run on a vthread, the body of a conversation.
  • Component — value describing renderable UI + handlers + per-instance state.
  • Conversation — live instance of a flow; one cid, one vthread, one SSE.
  • Ask — block the flow until a component answers.
  • Answer — fulfil the parking ask from inside a handler.
  • Show — push a fragment without blocking.
  • Reattach — bind a new SSE handle to an existing conversation after disconnect / refresh.
  • Reap — terminate a conversation (interrupt vthread, drop registry entry).

End of design doc.

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close