Lesson 11 — Live fragments: htmx from the graph
Goal: by the end of this lesson your page has a button that fetches a server-rendered fragment and swaps it into the page — no custom JS, no page reload — with the data, the markup and the wiring all visible in the graph.
Concepts introduced: web.htmx (:hx-get-attrs,
:hx-post-attrs, :hx-button, :hx-swap-mode),
:fragment-route / :fragment-post-route /
:html-fragment-handler (app.page), :with-htmx,
/assets/htmx.min.js (vendored — no CDN).
The idea
The editor's own popovers work this way (see
docs/PARTIALS.md): an element carries hx-get="/partials/…",
htmx fetches the URL, and the returned HTML swaps into a target.
web.htmx gives your pages the same vocabulary. A fragment is
just a fn returning hiccup, served by :fragment-route as
text/html with no page shell — htmx drops it into the DOM.
Each request executes the fragment's graph afresh, so whatever the graph computes — a query, a counter, a clock — is live.
Try it: a server clock
Three fn-defs. First the fragment — the current server time, recomputed on every request:
{:name :clock-fragment
:parent :wrap-element
:args {:tag "p"
:content {:parent :to-str
:args {:value {:parent :current-time-ms
:args {}}}}}}
Serve it at its own URL:
{:name :clock-fragment-route
:parent :fragment-route
:args {:path "/fragments/clock"
:fragment :clock-fragment}}
Then a page whose button fetches it. The button is a plain
:button with its :attrs built by :hx-get-attrs; the
:target names the element the fragment swaps into:
{:name :clock-page-body
:parent :stack
:args {:children
[{:parent :heading
:args {:level 2 :content "Server clock"}}
{:parent :button
:args {:label "Refresh"
:attrs {:parent :hx-get-attrs
:args {:url "/fragments/clock"
:target "#clock-out"}}}}
{:parent :card
:args {:children ["press Refresh"]
:attrs {:value {:id "clock-out"}}}}]}}
{:name :clock-page
:parent :html-page-route
:args {:path "/clock"
:title "Server clock"
:body :clock-page-body
:head {:parent :with-htmx
:args {:head :graphden-page-head}}
:scripts {:value []}}}
:with-htmx appends the htmx <script> to the head list you
give it — here on top of the default stylesheet head. The bundle
is served locally at /assets/htmx.min.js (vendored into the
platform, hash-busted per deploy), so pages work with no CDN and
no external dependency.
Mount :clock-page and :clock-fragment-route the same way as
any route (lesson 07: the :all items list locally, an app route
on the cloud). Open /clock, press Refresh — the number
changes on every click, straight from a fresh graph execution.
Auto-refresh — :trigger
:hx-get-attrs takes an optional :trigger — any htmx trigger
spec. Replace the button with a self-updating panel:
{:parent :card
:args {:children ["…"]
:attrs {:parent :hx-get-attrs
:args {:url "/fragments/clock"
:trigger "load, every 5s"}}}}
No :target — the fragment swaps into the element itself.
:swap (an :hx-swap-mode closed enum — the editor offers a
select) picks the strategy when innerHTML isn't what you want.
Forms — POST fragments
:hx-post-attrs on a <form> makes htmx serialize the fields
into the POST body; :hx-button is the one-liner button for it
(the htmx twin of :submit-button). On the server, a fragment
that reads the submitted fields needs the ring request — declare
your own handler child (the template itself can't, because
:lambda-params must name a real free arg):
{:name :vote-fragment
:parent :wrap-element
:args {:tag "p"
:content {:parent :str
:args {:parts
["you voted: "
{:parent :get
:args {:coll {:parent :parse-form-body
:args {:request {:as :request}}}
:key "choice"
:default "nothing"}}]}}}}
{:name :vote-fragment-handler
:lambda-params [:request]
:parent :html-fragment-handler
:args {:fragment :vote-fragment}}
{:name :vote-fragment-route
:parent :post-route
:args {:path "/fragments/vote"
:handler :vote-fragment-handler}}
The page side is a :form whose :attrs come from
:hx-post-attrs {:url "/fragments/vote" :target "#vote-out"} —
fields, button, target panel exactly as in the clock example.
Push, not poll — SSE streams
hx-trigger="every 5s" polls. For genuinely live panels the
server can PUSH instead: :sse-fragment-handler (app.page) keeps
the connection open as a Server-Sent-Events stream, re-renders
the fragment on an interval server-side, and pushes only when
the HTML changed. The client side is one attrs builder:
{:name :sse-clock-handler
:lambda-params [:request]
:parent :sse-fragment-handler
:args {:fragment :clock-fragment
:interval-ms 1000}}
{:name :sse-clock-route
:parent :get-route
:args {:path "/streams/clock"
:handler :sse-clock-handler}}
{:name :sse-clock-panel
:parent :card
:args {:children ["connecting…"]
:attrs {:parent :sse-connect-attrs
:args {:url "/streams/clock"}}}}
Put :sse-clock-panel in the page body, and take
:with-htmx-sse instead of :with-htmx in :head (it adds the
SSE extension on top of htmx — both served locally). The panel's
content is replaced on every push; unchanged ticks cost the
client nothing.
Event-driven, not just interval-driven
Add :wake-on-writes true to the handler and the interval stops
being the latency: any write on the platform's event bus (a graph
edit, a :create-entity from another page, a cron writing rows)
triggers one debounced extra render, so a data change reaches every
subscribed page in well under a second — while :interval-ms
degrades to a keepalive ceiling:
{:name :sse-clock-handler
:lambda-params [:request]
:parent :sse-fragment-handler
:args {:fragment :clock-fragment
:interval-ms 5000
:wake-on-writes true}}
Spurious wakes are cheap — a wake is one server-side render plus a
hash compare, and only a changed fragment is pushed. The demo
clock on /demo/contact runs exactly this way: save any fn in the
editor and watch the panel jump ahead of its 5-second keepalive.
Streams are bounded by design: each closes itself after
:max-lifetime-ms (default 5 min, capped at 30) and the
browser's EventSource transparently reconnects, so a page left
open keeps updating through stream generations. A
deployment-wide cap (GRAPHDEN_SSE_MAX_STREAMS, default 200)
turns overload into a clean 503 + retry instead of resource
exhaustion.
Live demo: the contact-form demo page (/demo/contact, lesson
07) now carries exactly this panel — a server clock streaming
over /demo/contact/clock.
When to use which layer
| Need | Take |
|---|---|
| Click → run a registered JS handler | :dispatch-action (lesson 07) |
| Click/submit → fetch a server fragment | web.htmx + :fragment-route (this lesson) |
| Server-pushed live panel (no polling) | :sse-connect-attrs + :sse-fragment-route (this lesson) |
| One-off DOM behaviour no vocabulary covers | :custom-script (lesson 08) |
htmx fragments keep the behaviour server-side: the fragment is a graph fn you can inspect, type-check, branch and reuse — the same property the editor relies on for its own UI.