Datastar
The stario.datastar submodule builds data-* attributes, declarative @… action strings, signal payloads, and server-sent events aligned with the upstream Datastar reference. This page is the Python API surface only.
For how this fits hypermedia, CQRS-shaped command/read splits, and Relay fan-out in real apps, read The go-to architecture.
In application code, prefer from stario import datastar as ds (same module as stario.datastar). Apidoc blocks below use the fully qualified stario.datastar.… paths.
The sections below are laid out in roughly that order: Runtime helpers (load Datastar in the document, read signals on the wire, FileSignal), Attributes (dict fragments for data-* on elements), Actions (@get, @post, @peek, … strings on data-on:*), and SSE events (writing Datastar’s event stream through a Writer from your handlers).
The package also re-exports js and s from stario.datastar.format and JSEvent from the attributes layer. Use them when you build custom attribute values or event payloads; most apps stick to the helpers below and only reach for these when the typed builders are not enough.
Runtime helpers
A small trio on top of the rest of the module. ModuleScript() emits the type="module" <script> that loads Datastar (typically in the document head). read_signals() reads the JSON signals blob Datastar sends on each request (GET uses the datastar query param; otherwise the body). FileSignal is a typing.TypedDict that describes one nested file object inside that payload when you want static typing (see below)—not a substitute for validation.
ModuleScript(src='https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.0-RC.8/bundles/datastar.js')
Load the Datastar client (type="module"). Override src only if you self-host.
from stario import datastar as dsfrom stario.html import HtmlDocument, Head, Body, P HtmlDocument(Head(ds.ModuleScript()), Body(P("Hello")))# <!doctype html><html><head><script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.0-RC.8/bundles/datastar.js"></script></head><body><p>Hello</p></body></html>async read_signals(req)
Parse the JSON signals blob Datastar sends (GET: datastar query param; else body).
Raises
TypeError: If the decoded JSON value is not an object. json.JSONDecodeError: If the query string or body is not valid JSON.
Convenience only: this uses the stdlib json module on the raw bytes or query string. Incoming signals are untrusted; validate types, sizes, and nested shapes (Pydantic, msgspec, cattrs, hand-rolled checks, …). We recommend reading await req.body() or req.query.get("datastar", "") directly when you want a schema library to decode and validate from bytes in one step; that is often faster than json.loads into a plain dict through this helper.
@app.post("/action")async def action(c, w): sig = await ds.read_signals(c.req) n = int(sig.get("n", 0)) ds.sse.patch_signals(w, {"n": n + 1})Signal JSON from the client is untrusted. read_signals is only a thin json.loads wrapper over the bytes or query string Stario already exposes—you remain responsible for validating shape, sizes, and file payloads.
Prefer
await req.body()or thedatastarquery value directly when you want decoding and validation in one pass with Pydantic, msgspec, cattrs, or similar; that is often faster thanjson.loadsinto a plain dict via this helper.For a step-by-step app-level pattern (typed reads, thin
patch_*helpers), see Reading and writing Datastar signals and No validation layer in the framework.
FileSignal
How uploads land in signals is described in the File Uploads section of the Datastar attributes docs (data-bind). FileSignal is not multipart/form-data; it is the nested object (filename, base64 contents, optional mime) Datastar puts under a signal key.
After you read the payload (for example with read_signals above), you can narrow one entry for editors and type checkers. TypedDict is structural only—you still need your own checks before trusting bytes or paths:
from stario import Context, Writer, datastar as dsfrom stario.datastar import FileSignal async def avatar_meta(c: Context, w: Writer) -> None: payload = await ds.read_signals(c.req) f: FileSignal = payload["avatar"] # validate name, decode base64 contents, …JSON shape for that key:
{"avatar": {"name": "photo.png", "contents": "<base64>", "mime": "image/png"}}Attributes
Datastar’s reactive UI is driven by data-* attributes on elements (signals, bindings, event actions, and modifiers). These helpers return small dict fragments—usually dict[str, str]. Pass them as the first arguments to a Stario tag (before any children), along with any extra attribute dicts you need; the tag merges those mappings into the element so the client sees the same names and values the upstream docs describe.
Authoritative names, modifier syntax, and behavior live in the Datastar attributes reference. Helpers that target one key vs many use the singular names (attr, class_, computed, style) versus the plural ones (attrs, classes, computeds, styles). For signals, signal sets a namespaced data-signals:<key> entry; signals sets the data-signals attribute to a serialized JSON object (see stario.datastar helpers’ docstrings for __ifmissing variants).
ModuleScript() defaults to the bundled Datastar script URL from DATASTAR_CDN_URL; override src= when self-hosting.
attr(key, expression)
One data-attr:key entry (name + expression).
h.Div(ds.attr("title", "$item.label"), {"class": "tooltip"})# <div data-attr:title="$item.label" class="tooltip"></div>attrs(mapping)
Several custom attrs at once via data-attr with a JS object literal.
h.Aside(ds.attrs({"open": "sidebarOpen"}), {"class": "drawer"})# <aside data-attr="{'open':sidebarOpen}" class="drawer"></aside>bind(signal_name)
data-bind — two-way bind a signal to an input or similar.
h.Input(ds.bind("email"), {"type": "email", "class": "input input-bordered"})# <input data-bind="email" type="email" class="input input-bordered"/>class_(name, expression)
One data-class:name entry (class token + expression).
h.Div(ds.class_("hidden", "!$expanded"))# <div data-class:hidden="!$expanded"></div>classes(mapping)
Several class toggles at once via data-class with a JS object literal.
h.Ul(ds.classes({"loading": "$pending", "text-error": "$error != null"}))# <ul data-class="{'loading':$pending,'text-error':$error != null}"></ul>computed(key, expression)
One data-computed:key entry.
h.Span(ds.computed("fullName", "$first + ' ' + $last"))# <span data-computed:full-name="$first + ' ' + $last"></span>computeds(mapping)
Several computed fields at once (each key becomes a data-computed entry).
h.Div(ds.computeds({"fullName": "$first + ' ' + $last", "initials": "$first[0]"}))# <div data-computed:full-name="$first + ' ' + $last" data-computed:initials__case.kebab="$first[0]"></div>effect(expression)
data-effect — run a client expression when the element is patched.
h.Div(ds.effect("el.querySelector('input')?.focus()"))# <div data-effect="el.querySelector('input')?.focus()"></div>ignore(self_only=False)
data-ignore — skip morphing / updates for this subtree (or self only).
h.Div(ds.ignore(), h.P("Third-party widget root"))# <div data-ignore><p>Third-party widget root</p></div>ignore_morph()
data-ignore-morph — keep DOM here from being morphed.
h.Textarea(ds.ignore_morph(), {"name": "notes"})# <textarea data-ignore-morph name="notes"></textarea>indicator(signal_name)
data-indicator — bind a signal while fetches run.
h.Span(ds.indicator("saving"), "Saving…")# <span data-indicator="saving">Saving…</span>init(expression, *, delay=None, viewtransition=False)
data-init — run once when the element enters the page.
h.Div(ds.init("$focusFirstInput(el)"), {"id": "form-shell"})# <div data-init="$focusFirstInput(el)" id="form-shell"></div>h.Div(ds.init("loadMore()", delay="200ms"), {"id": "infinite-sentinel"})# <div data-init__delay.200ms="loadMore()" id="infinite-sentinel"></div>json_signals(*, include=None, exclude=None, terse=False)
data-json-signals — control how signals serialize on requests (include/exclude filters).
h.Form(ds.json_signals(include=["email", "password"]), {"action": "/login", "method": "post"})# <form data-json-signals="{'include':'email|password'}" action="/login" method="post"></form>on(event, expression, *, once=False, passive=False, capture=False, delay=None, debounce=None, throttle=None, viewtransition=False, window=False, outside=False, prevent=False, stop=False)
data-on:* — DOM (or window) events with optional modifiers.
h.Button(ds.on("click", ds.get("/cart/count")), {"type": "button", "class": "btn"})# <button data-on:click="@get('/cart/count')" type="button" class="btn"></button>h.Input(ds.on("keydown", "$query = el.value", debounce=("150ms", "leading")))# <input data-on:keydown__debounce.150ms.leading="$query = el.value"/>on_intersect(expression, *, once=False, half=False, full=False, delay=None, debounce=None, throttle=None, viewtransition=False)
data-on-intersect — Intersection Observer → expression.
h.Div(ds.on_intersect("@get('/feed?cursor=' + $cursor)", once=True), {"id": "sentinel"})# <div data-on-intersect__once="@get('/feed?cursor=' + $cursor)" id="sentinel"></div>on_interval(expression, *, duration='1s', viewtransition=False)
data-on-interval — timer-driven expression.
h.Div(ds.on_interval("$pollInbox()", duration="5s"))# <div data-on-interval__duration.5s="$pollInbox()"></div>on_signal_patch(expression, *, delay=None, debounce=None, throttle=None, include=None, exclude=None)
data-on-signal-patch — react to signal changes (optional include/exclude filters).
h.Div(ds.on_signal_patch("@post('/autosave')", debounce="500ms", include=["draft"]))# <div data-on-signal-patch__debounce.500ms="@post('/autosave')" data-on-signal-patch-filter="{'include':'draft'}"></div>preserve_attr(attrs)
data-preserve-attr — attribute names to keep across morphs.
h.Div(ds.preserve_attr(["data-testid", "id"]), {"class": "card"})# <div data-preserve-attr="data-testid id" class="card"></div>ref(signal_name)
data-ref — store the element on a signal (e.g. for focus helpers).
h.Input(ds.ref("searchInput"), ds.bind("q"), {"type": "search"})# <input data-ref="searchInput" data-bind="q" type="search"/>show(expression)
data-show — visibility from a boolean expression.
h.Div({"class": "alert alert-error", "role": "alert"}, ds.show("$error != null"))# <div class="alert alert-error" role="alert" data-show="$error != null"></div>signal(name, expression, *, ifmissing=False)
One data-signals:key entry on this element.
h.Div({"class": "theme-root"}, ds.signal("theme", "'dark'"))# <div class="theme-root" data-signals:theme__case.kebab="'dark'"></div>signals(data, *, ifmissing=False)
Initial JSON signals object from a dict, dataclass instance, or similar (data-signals).
h.Body(ds.signals({"count": 0, "open": False}), h.Main(...))# <body data-signals="{"count":0,"open":false}"><main>…</main></body>style(prop, expression)
One data-style:property entry.
h.Div({"class": "bar-fill"}, ds.style("width", "$pct + '%'"))# <div class="bar-fill" data-style:width="$pct + '%'"></div>styles(mapping)
Several style properties at once via data-style with a JS object literal.
h.Div(ds.styles({"opacity": "$visible ? '1' : '0'"}))# <div data-style="{'opacity':$visible ? '1' : '0'}"></div>text(expression)
data-text — text content from an expression.
h.P(ds.text("$greeting"))# <p data-text="$greeting"></p>h.Span({"class": "font-mono"}, ds.text("$user.name"))# <span class="font-mono" data-text="$user.name"></span>Actions
Declarative @… strings for data-on:*: @get, @post, @put, @patch, and @delete share the same option surface (query string, signal filters, selector, headers, retry, requestCancellation, …). peek, set_all, toggle_all, clipboard, and fit cover the other action verbs Datastar accepts. Combine these with on() for attributes; live updates are delivered with sse.* on the server, not a separate @…SSE client action.
For annotations on your own helpers, ContentType, RequestCancellation, and Retry are Literal aliases in stario.datastar.actions (re-exported on stario.datastar as well).
get(url, queries=None, *, content_type='json', include=None, exclude=None, selector=None, headers=None, open_when_hidden=False, payload=None, retry='auto', retry_interval_ms=1000, retry_scaler=2.0, retry_max_wait_ms=30000, retry_max_count=10, request_cancellation='auto')
@get action: declarative GET with options (queries, signal filters, retry, …).
h.Button(ds.on("click", ds.get("/items", {"q": "$query"})), "Search")# <button data-on:click="@get('/items?q=%24query')">Search</button>post(url, queries=None, *, content_type='json', include=None, exclude=None, selector=None, headers=None, open_when_hidden=False, payload=None, retry='auto', retry_interval_ms=1000, retry_scaler=2.0, retry_max_wait_ms=30000, retry_max_count=10, request_cancellation='auto')
@post — same option surface as get.
h.Form(ds.on("submit", ds.post("/login", payload={"user": "$email"})))# <form data-on:submit="@post('/login', {payload: {'user':$email}})"></form>put(url, queries=None, *, content_type='json', include=None, exclude=None, selector=None, headers=None, open_when_hidden=False, payload=None, retry='auto', retry_interval_ms=1000, retry_scaler=2.0, retry_max_wait_ms=30000, retry_max_count=10, request_cancellation='auto')
@put — same option surface as get.
h.Button(ds.on("click", ds.put("/api/r")), "Go")# <button data-on:click="@put('/api/r')">Go</button>patch(url, queries=None, *, content_type='json', include=None, exclude=None, selector=None, headers=None, open_when_hidden=False, payload=None, retry='auto', retry_interval_ms=1000, retry_scaler=2.0, retry_max_wait_ms=30000, retry_max_count=10, request_cancellation='auto')
@patch — same option surface as get.
h.Button(ds.on("click", ds.patch("/api/r")), "Go")# <button data-on:click="@patch('/api/r')">Go</button>delete(url, queries=None, *, content_type='json', include=None, exclude=None, selector=None, headers=None, open_when_hidden=False, payload=None, retry='auto', retry_interval_ms=1000, retry_scaler=2.0, retry_max_wait_ms=30000, retry_max_count=10, request_cancellation='auto')
@delete — same option surface as get.
h.Button(ds.on("click", ds.delete("/api/r")), "Go")# <button data-on:click="@delete('/api/r')">Go</button>peek(callable_expr)
@peek(expr) — read a value in an action string without subscribing.
ds.peek("JSON.stringify($cart)")# → ``@peek(JSON.stringify($cart))`` (embedded in ``data-on:*`` alongside other actions)set_all(value, include=None, exclude=None)
@setAll — bulk-assign signals (optional include/exclude regex list).
h.Button(ds.on("click", ds.set_all("null", include=["draft", "attachments"])))# <button data-on:click="@setAll(null, {'include':'draft|attachments'})"></button>toggle_all(include=None, exclude=None)
@toggleAll — flip booleans that match filters.
h.Button(ds.on("click", ds.toggle_all(include=["showSidebar", "showHelp"])))# <button data-on:click="@toggleAll({'include':'showSidebar|showHelp'})"></button>clipboard(text, is_base64=False)
@clipboard — copy a literal (or base64) string client-side.
h.Button(ds.on("click", ds.clipboard("https://stario.dev")))# <button data-on:click="@clipboard('https://stario.dev')"></button>fit(v, old_min, old_max, new_min, new_max, should_clamp=False, should_round=False)
@fit — remap a numeric expression from one range to another.
ds.fit("$slider", 0, 100, 0, 1, should_clamp=True)# → '@fit($slider, 0, 100, 0, 1, true, false)'SSE events
These functions take a Writer first. They target long-lived or streaming responses: the first write sets Content-Type: text/event-stream, Cache-Control: no-cache, and Connection: keep-alive; after that, each call appends one Datastar SSE frame (event: / data: as in the Datastar reference). Typical pattern: keep the connection open and patch over time (for example with async with w.alive()).
patch_elements— HTML patches (replace, append, remove-by-selector, …).patch_signals— merge a JSON object into the client signal store.execute,remove— thin conveniences on the same element-patch path.redirect— client-side navigation via streamed script (not an HTTP3xx; useresponses.redirectwhen you want a normal redirect response).
patch_elements(w, content, *, mode='outer', selector=None, namespace=None, use_view_transition=False)
Stream an element patch (replace, append, …). content may be bytes, str, or HTML nodes.
async def live(c, w): async with w.alive(): ds.sse.patch_elements(w, h.Div(h.P("Hello"))) # SSE: event: datastar-patch-elements … data: elements <div><p>Hello</p></div> await asyncio.sleep(1) ds.sse.patch_elements(w, h.Div(h.P("Updated")), selector="#slot", mode="inner") # SSE: … data: mode inner … data: selector #slot … data: elements <div><p>Updated</p></div>patch_signals(w, payload, *, only_if_missing=False)
Push signal updates to the client (merge into the page store).
ds.sse.patch_signals(w, {"count": 2, "status": "ok"})# SSE: event: datastar-patch-signals … data: signals {"count":2,"status":"ok"}execute(w, code, *, auto_remove=True)
Run JS on the client by patching a script element (removed by default).
Convenience over patch_elements: serializes code into <script> HTML and streams an append-to-body patch (same wire path as other element patches).
ds.sse.execute(w, "console.info('tick')")# SSE: … data: elements <script data-effect="el.remove();">console.info('tick')</script>remove(w, selector)
Remove nodes matching selector via a patch.
ds.sse.remove(w, "#toast")# SSE: event: datastar-patch-elements … data: mode remove … data: selector #toastredirect(w, url)
Client-side navigation (not an HTTP 3xx): streams a script that sets window.location.
The browser navigates after it applies the SSE patch—unlike responses.redirect(), there is no Location response header.
ds.sse.redirect(w, "/done")# SSE: … data: elements <script data-effect="el.remove();">setTimeout(() => window.location = "/done");</script>