The go-to architecture
This page walks through the architecture Stario pairs with Datastar when you build live, hypermedia-heavy UIs—CQRS-shaped HTTP, a long-lived SSE subscription, actions that often return quickly while work finishes in the background, a small Relay for in-process fan-out, and signals as a thin client-side mirror—not a second source of truth. Not every Stario app needs Datastar; for values and escape hatches, see The Stario way. Read this page top to bottom once; later sections build on earlier ones.
Datastar is the browser runtime: declarative data-* attributes, signals, fetches, and applying HTML patches from the server. The stario.datastar package is the Python side—data, at, read_signals, and SSE (Datastar). It is not a second server; it pairs HTML you render in Python with Datastar in the tab.
from stario import UrlPathfrom stario.datastar import SSE, at, data, read_signalsWhat CQRS is (and why it is central here)
CQRS (Command Query Responsibility Segregation) names a simple split: commands change system state; queries read state and answer “what should we show now?” The two are intentionally different operations—different validation, different scaling, different failure modes.
That separation matters for Stario + Datastar because writing (handling a user intent, mutating persistence, validating business rules) is not the same job as showing the current state (building the HTML or patches the user should see after those rules run). Your handlers can keep that distinction clear: mutate in one path, re-query authoritative state when you need to render.
CQRS with Datastar: the loop
In a typical live UI you end up with a circle:
Initial document —
GET /pagereturns HTML for the first paint (signals, links, static structure).Subscription — the page opens
GET /subscribe(or similar) as Server-Sent Events — on the client,data.init(at.get(SUBSCRIBE.href(), retry="always"))on the page shell (Realtime tiles). That connection pushes updates into the document for as long as the tab stays open.Actions — user intent goes out as
POST,PATCH,DELETE(orGETwhen that matches your cache rules). Return quickly—often204 No Content—then mutate andrelay.publishin the same handler, or defer heavier work withapp.create_task/ a queue worker.Notify — when work finishes (or when shared state changes), something must tell subscribers there is new truth to show. In Stario,
Relay.publishis the usual in-process hook (Relay).Re-render path — the code that handles
/subscribe(or the loop it triggers) re-reads state from your database, cache, or services, re-renders HTML, and sends patches over SSE.
So the high-level flow is:
state → action → relay → subscribe → new state
Two parallel lanes, not a strict pipeline:
Query lane —
GET /subscribe(or similar) stays open; each notification is a reason to re-read authoritative state and render or patch. Ordering of notifications can differ from ordering of background work.Command lane — HTTP actions and
app.create_task/ queue work mutate state;Relay.publish(or similar) nudges subscribers that truth may have changed.
The subscribe side is still a query: it answers “what should the UI look like now?” The action side is the command lane. Keeping that split explicit avoids mixing “I processed a click” with “here is the full new page” in one ad hoc response—unless you want that for a specific route.
This doc uses CQRS-shaped language for that command/read split over HTTP—it is not claiming event-sourced write models or separate read databases unless you add them.
Routes as UrlPath
Declare routes once and reuse them in registration and HTML:
HOME = UrlPath("/")SUBSCRIBE = UrlPath("/subscribe")CLICK = UrlPath("/click") app.get(HOME, home(game))app.get(SUBSCRIBE, subscribe(game, relay))app.post(CLICK, click(game, relay))In views, SUBSCRIBE.href() and CLICK.href(query=...) build the URLs Datastar actions call—no reverse registry.
The /subscribe endpoint: SSE, efficiency, and patches
/subscribe is usually a long-lived SSE response: one request, many events, each carrying a fragment of work for the client—often HTML to merge into the DOM via Datastar’s morph pipeline (patch_elements, Writer). Use c.alive(...) so the handler stays tied to the connection lifecycle (disconnect, shutdown) for the whole stream.
user_id = (await read_signals(c.req)).get("user_id", "")async with relay.subscribe("*") as live: relay.publish("join", user_id) # enter subscribe before publishes this connection must see sse = SSE(w) sse.patch_elements(home_view(user_id, game)) async for subject, _ in c.alive(live): sse.patch_elements(home_view(user_id, game))Only the /subscribe handler writes HTML patches on its Writer; command handlers publish notifications—they do not stream HTML on the POST response.
Why SSE instead of WebSockets (for this shape)? For server-originated push of text events, SSE is simple: HTTP semantics, works through many proxies, reconnect story is standardized. WebSockets are heavier when you do not need a full duplex binary pipe; Stario’s docs and examples lean on SSE plus HTTP actions first.
Optional transport detail: Stario’s server speaks HTTP/1.1. When the browser or a reverse proxy uses HTTP/2 or HTTP/3 to the origin, multiplexing means many requests and streams share one connection; header compression (HPACK, QPACK) and reuse of that connection make additional short requests—actions alongside /subscribe—cheap and straightforward. You still model actions as normal HTTP; you do not need a second transport just to avoid “too many round trips” when the browser already has an efficient connection to the origin.
The SSE format defines event IDs and Last-Event-ID for resumability after reconnect. Stario’s SSE helper does not set SSE id: fields today; after a drop, re-open /subscribe and send fresh state.
Each event is often an HTML patch (or a small script or signal patch). The client does not reload the whole page; Datastar applies the payload to the live DOM.
Large HTML patches (“fat morphs”), compression, and morphing
Fat morph is a strategy: you send large HTML fragments (up to a full document) and rely on Datastar’s morph step to merge them into the live tree.
From a practical workflow, the recommendation is simpler: ship what you need—often a full re-render of the page or shell on each meaningful update—and only optimize (smaller patches, signal-only updates, finer selectors) when you have observed a real bottleneck. That matches avoiding premature optimization: measure first, then narrow payloads or branch logic.
When you do send large fragments, two mechanisms work in your favor:
Compression — Stario negotiates
Content-Encodingper response on eachWriter(Writer — Compression, Configuration).Morphing — Datastar’s merge does not throw away the whole DOM on each patch. It updates what changed and leaves untouched subtrees alone, so local state on stable nodes (focus, third-party widgets, scroll in unpatched regions) survives more often than with a full
innerHTMLreplace.
ui = f(state)
A useful rule: what the user sees should be a function of authoritative state you already keep—database, memory, downstream services—not of whatever happened to be on the event bus last. After an action completes, re-query that truth and render. Pushing raw domain events into the SSE payload is possible, but mixing “event log as UI driver” is a design choice; many teams prefer events for notifications, queries for pixels.
Actions: how the browser talks back
The usual starting point is declarative actions: data-on:* (via data.on, at.post, …) and signals that carry the last client-side form or field state when something interesting happens (Reading and writing Datastar signals).
data.on( "click", f""" let id = evt.target.dataset.cellId; if (id) {{ @post(`{CLICK.href()}?cellId=${{id}}`); }} """,)From there you can optimize:
Event bubbling — listeners on a parent can inspect
event.targetso many children share one handler (fewer attributes, one place to branch). On the originating element you can adddata-*attributes (for exampledata-row-id,data-index) and read them in the delegated handler viaevent.target.dataset—a small, explicit channel for ids, indexes, or flags without stuffing everything into signals.Query parameters — there is no rule that every parameter must live in signals. Putting action or id in the URL is fine and often clearer for logs and links.
One handler vs many — a single
POST /actionthat dispatches on a field is often easier to evolve than dozens of tiny routes. The opposite is also valid; it is a preference and team-style choice.
Relay: the notification bus
Relay connects publishers (commands finished, timers, other workers) to subscribers (SSE loops waiting to push new HTML). The command path often does not need to embed the new UI; it publishes “something changed” (topic + optional payload). The subscription path re-reads state and renders.
In most apps, Relay carries notifications only—not the full payload the UI needs. That keeps the bus small and cheap. You can still put data on events (for example: “command failed”) and let /subscribe send a toast or a tiny patch instead of a full morph.
from stario import Relay is in-process and single-process (Toolbox). For multiple workers or hosts, use a broker (NATS, Redis, …) and keep the same shape: publish after commit, subscribe in the SSE loop, re-query before patch.
Datastar signals: not the same as server state
Signals are JSON-shaped values mirrored to the server on actions (read_signals). They are not a substitute for authoritative server state. Prefer few signals: URLs, hidden fields, and server-rendered error regions often cover more than you think.
Practical roles for signals: form fields, scroll position on long pages, ephemeral input the server must see on the next action. When validation fails, the server can re-render the same page with errors in the HTML—you do not need a parallel “error signal” for every field.
A game analogy
Think of signals as the player transform—position and rotation you send up. The server is the world: it decides what is visible, whether a move is legal, what happens when you walk into a wall or fall off a cliff. Actions are inputs; the server remains authoritative on what the player may see and how state transitions happen. That is the same separation as commands vs queries: intent in, truth out.
Version pin
stario.datastar exposes DATASTAR_CDN_URL for quick experiments; bundled templates ship a local copy so tutorials work offline. For production, align the Datastar client version with how you ship HTML and static assets.
Related
Handlers and the writer —
c.alive(), 204-then-continue, writer ownershipHello world — signals and
SSE(w).patch_signalsRealtime with tiles — a full project layout using these pieces
Reading and writing Datastar signals — validation, signals,
patch_signalsDatastar —
patch_elements, SSE helpers,c.alive()Relay — in-process pub/sub
Writer — Compression — per-response
Content-Encoding