Lesson 14 — 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,
:sse-connect-attrs), :fragment-route /
:html-fragment-handler / :sse-fragment-handler (app.page),
:wake-on-writes, :with-htmx / :with-htmx-sse,
/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 in an isolated HTML preview
The interactive lesson needs an account browser session, manage-apps
access and a deployment with an isolated apps domain. Extend
web.response/html-ok-response and bind its :body to the HTML string below.
Open ⋯ → Apps, choose a unique subdomain label and click + Add app.
This ordinary app route provides the organization's isolated host; the lesson
records its exact UUID for cleanup. Then close Apps and choose ⋯ → HTTP →
Preview HTML handler → Open HTML preview. The preview captures the exact
current branch and starts no background service. Finish removes the
lesson's app route before removing its handler and branch.
<!doctype html><html><head><meta name="htmx-config" content='{"selfRequestsOnly":false,"withCredentials":false,"allowEval":false}'><script src="assets/htmx.min.js"></script></head><body><button hx-get="fragment" hx-select="#fragment" hx-target="#out">Refresh</button><div id="out"><p id="fragment">First fragment</p></div></body></html>
Keep the preview tab open. Edit only First fragment to Second fragment
in the graph's saved body. In the preview tab click Refresh: htmx makes
an HTTPS request to fragment, selects #fragment from the response and
replaces #out, without a page reload. The full page remains in the response
so opening a fresh preview still gives you the same working button. A handler
can instead branch on request.uri and return just the requested fragment;
the regular route example below shows that separation.
The preview link expires after two minutes. If it is denied, return to the same graph's HTTP popover, click Preview HTML handler again and open the new link. Close the expired tab and continue with the new one; you do not need to recreate the graph. Treat the URL as a temporary capability: do not share or log it. Deleting its captured branch denies requests; it never falls back to main.
The sandbox has an opaque origin and cannot read editor or app cookies.
Only the relative vendored assets/htmx.min.js script is available. The
explicit htmx configuration permits its cross-origin sandbox request without
credentials or evaluated expressions. Requests stay inside that capsule;
external scripts, redirects, response cookies and streaming responses are
not allowed. Existing app routes below support the wider page/stream
examples on a configured app origin. On a self-hosted installation without
an isolated apps domain, use such an independently configured origin; the
temporary text/JSON host never serves HTML on the editor origin.
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 10's :all list on a self-hosted instance, or
as an app — lesson 30). 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.
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
10) carries exactly this panel — a server clock streaming over
/demo/contact/clock; save any fn in the editor and watch it
jump ahead of its 5-second keepalive.
When to use which layer
| Need | Take |
|---|---|
| Click → run a registered JS handler | :dispatch-action (lesson 10) |
| Click/submit → fetch a server fragment | web.htmx + :fragment-route (this lesson) |
| Server-pushed live panel (no polling) | :sse-connect-attrs + :sse-fragment-handler on a :get-route (this lesson) |
| One-off DOM behaviour no vocabulary covers | :custom-script (lesson 11) |
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.