Datastar
Datastar 1.0 compatibility
The stario.datastar helpers target Datastar version 1.0: attribute names, action strings, signal JSON on the wire, and SSE framing match that release line. Use a 1.0-compatible browser bundle in the page; the default ModuleScript() URL pins v1.0.1 (getting started)—override src= when you load a different build.
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 Case, 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 and DELETE use the datastar query param; other methods use the body, per Datastar #1146). 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.1/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.1/bundles/datastar.js"></script></head><body><p>Hello</p></body></html>async read_signals(req)
Parse the JSON signals blob Datastar sends.
Raises
TypeError: If the decoded JSON value is not an object. json.JSONDecodeError: If the query string or body is not valid JSON.
GET and DELETE use the datastar query parameter; all other methods use the request body, matching upstream Datastar after https://github.com/starfederation/datastar/pull/1146.
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() uses DATASTAR_CDN_URL as its default src (the compatibility note at the top of this page describes the pin); override src= when self-hosting.
Some helpers correspond to Datastar Pro attributes: they emit the same data-* names as upstream, but the open-source browser bundle ignores them unless Pro is enabled. Use them only when your client includes Pro.
attr(key, expression)
Set one HTML attribute from a reactive expression.
Official Datastar docs: <https://data-star.dev/reference/attributes#data-attr>
h.Div(ds.attr("title", "$item.label"), {"class": "tooltip"})# <div data-attr:title="$item.label" class="tooltip"></div>attrs(mapping)
Set several HTML attributes at once from reactive expressions.
Official Datastar docs: <https://data-star.dev/reference/attributes#data-attr>
h.Aside(ds.attrs({"open": "sidebarOpen"}), {"class": "drawer"})# <aside data-attr="{'open':sidebarOpen}" class="drawer"></aside>bind(signal_name, *, case=None, prop=None, event=None)
data-bind — two-way bind a signal to an input or similar.
Uses :func:~stario.datastar.format.to_kebab_key for the attribute key. Value form is data-bind="signal"; key forms use data-bind:key...="signal" (same identifier as the value).
Official Datastar docs: <https://data-star.dev/reference/attributes#data-bind>
h.Input(ds.bind("email"), {"type": "email", "class": "input input-bordered"})# <input data-bind="email" type="email" class="input input-bordered"/> h.Input(ds.bind("isChecked", prop="checked", event="change"))# <input data-bind:is-checked__prop.checked__event.change="isChecked" /> h.Input(ds.bind("mySignal", case="kebab"))# <input data-bind:my-signal__case.kebab="mySignal" />class_(name, expression)
Toggle one CSS class from a reactive expression.
Official Datastar docs: <https://data-star.dev/reference/attributes#data-class>
h.Div(ds.class_("hidden", "!$expanded"))# <div data-class:hidden="!$expanded"></div>classes(mapping)
Toggle several CSS classes at once from reactive expressions.
Official Datastar docs: <https://data-star.dev/reference/attributes#data-class>
h.Ul(ds.classes({"loading": "$pending", "text-error": "$error != null"}))# <ul data-class="{'loading':$pending,'text-error':$error != null}"></ul>computed(key, expression)
Create one computed signal from a reactive expression.
Official Datastar docs: <https://data-star.dev/reference/attributes#data-computed>
h.Span(ds.computed("fullName", "$first + ' ' + $last"))# <span data-computed:full-name="$first + ' ' + $last"></span>computeds(mapping)
Create several computed signals at once from a mapping of expressions.
Official Datastar docs: <https://data-star.dev/reference/attributes#data-computed>
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)
Run a client-side side effect when the element is initialized or updated.
Official Datastar docs: <https://data-star.dev/reference/attributes#data-effect>
h.Div(ds.effect("el.querySelector('input')?.focus()"))# <div data-effect="el.querySelector('input')?.focus()"></div>ignore(self_only=False)
Tell Datastar to ignore this element or its whole subtree during processing.
Official Datastar docs: <https://data-star.dev/reference/attributes#data-ignore>
h.Div(ds.ignore(), h.P("Third-party widget root"))# <div data-ignore><p>Third-party widget root</p></div>ignore_morph()
Prevent Datastar morphing from changing this element and its descendants.
Official Datastar docs: <https://data-star.dev/reference/attributes#data-ignore-morph>
h.Textarea(ds.ignore_morph(), {"name": "notes"})# <textarea data-ignore-morph name="notes"></textarea>indicator(signal_name)
Track in-flight fetch state in a signal for loading indicators and disabled UI.
Official Datastar docs: <https://data-star.dev/reference/attributes#data-indicator>
h.Span(ds.indicator("saving"), "Saving…")# <span data-indicator="saving">Saving…</span>init(expression, *, delay=None, viewtransition=False)
Run a client expression when the element is initialized in the DOM.
Official Datastar docs: <https://data-star.dev/reference/attributes#data-init>
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)
Control how signals are serialized for inspection with optional include/exclude filters.
Official Datastar docs: <https://data-star.dev/reference/attributes#data-json-signals>
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)
Listen for DOM or window events and run a Datastar expression.
Official Datastar docs: <https://data-star.dev/reference/attributes#data-on>
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, *, threshold=None, once=False, full=False, delay=None, debounce=None, throttle=None)
data-on-intersect — Intersection Observer → expression.
Matches the data-on-intersect modifiers: __threshold, __delay, __debounce, __throttle, plus once and full as in the reference example (modifier order: threshold, then once / full, then timing modifiers).
Official Datastar docs: <https://data-star.dev/reference/attributes#data-on-intersect>
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> h.Div(ds.on_intersect("$loaded = true", threshold=0.25, once=True, full=True))# <div data-on-intersect__threshold.25__once__full="$loaded = true"></div>on_interval(expression, *, duration='1s', viewtransition=False)
Run a client expression on an interval, optionally with a custom duration.
Official Datastar docs: <https://data-star.dev/reference/attributes#data-on-interval>
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)
React to signal patch events, optionally filtered to specific signals.
Official Datastar docs: <https://data-star.dev/reference/attributes#data-on-signal-patch>
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>type Case = Literal['kebab', 'snake', 'pascal', 'camel']
animate(expression)
Datastar Pro only. Animate element attributes over time from a reactive expression.
Requires a Datastar Pro license and client bundle. Official Datastar docs:
custom_validity(expression)
Datastar Pro only. Set a custom validity message from a reactive expression.
Requires a Datastar Pro license and client bundle. Official Datastar docs:
match_media(signal_name, expression, *, case=None)
Datastar Pro only. Sync a signal with a window.matchMedia query.
Modifiers match the reference: optional __case (.camel / .kebab / …).
Requires a Datastar Pro license and client bundle. Official Datastar docs:
on_raf(expression, *, throttle=None)
Datastar Pro only. Run a Datastar expression on every animation frame.
Requires a Datastar Pro license and client bundle. Official Datastar docs:
on_resize(expression, *, debounce=None, throttle=None)
Datastar Pro only. React to element resize events with a Datastar expression.
Requires a Datastar Pro license and client bundle. Official Datastar docs:
persist(*, filter_signals=None, storage_key=None, session=False)
Datastar Pro only. Persist signals in local or session storage.
filter_signals becomes the attribute value as a JS object (include / exclude regexes).
Requires a Datastar Pro license and client bundle. Official Datastar docs:
query_string(*, filter_signals=None, filter_empty=False, history=False)
Datastar Pro only. Sync matching signals with the page query string.
Requires a Datastar Pro license and client bundle. Official Datastar docs:
replace_url(expression)
Datastar Pro only. Replace the current browser URL from a reactive expression.
Requires a Datastar Pro license and client bundle. Official Datastar docs:
scroll_into_view(*, smooth=False, instant=False, auto=False, hstart=False, hcenter=False, hend=False, hnearest=False, vstart=False, vcenter=False, vend=False, vnearest=False, focus=False)
Datastar Pro only. Scroll the element into view with optional focus behavior.
Requires a Datastar Pro license and client bundle. Official Datastar docs:
view_transition(expression)
Datastar Pro only. Set an explicit view-transition-name from an expression.
Requires a Datastar Pro license and client bundle. Official Datastar docs:
preserve_attr(attrs)
Preserve selected attribute values when Datastar morphs the DOM.
Official Datastar docs: <https://data-star.dev/reference/attributes#data-preserve-attr>
h.Div(ds.preserve_attr(["data-testid", "id"]), {"class": "card"})# <div data-preserve-attr="data-testid id" class="card"></div>ref(signal_name)
Store the current element in a signal so expressions can reference it later.
Official Datastar docs: <https://data-star.dev/reference/attributes#data-ref>
h.Input(ds.ref("searchInput"), ds.bind("q"), {"type": "search"})# <input data-ref="searchInput" data-bind="q" type="search"/>show(expression)
Show or hide an element based on a boolean expression.
Official Datastar docs: <https://data-star.dev/reference/attributes#data-show>
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)
Patch one signal on this element from a Datastar expression.
Official Datastar docs: <https://data-star.dev/reference/attributes#data-signals>
h.Div({"class": "theme-root"}, ds.signal("theme", "'dark'"))# <div class="theme-root" data-signals:theme__case.kebab="'dark'"></div>signals(data, *, ifmissing=False)
Patch several signals at once from a dict, dataclass, or similar object.
Official Datastar docs: <https://data-star.dev/reference/attributes#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)
Set one inline style property from a reactive expression.
Official Datastar docs: <https://data-star.dev/reference/attributes#data-style>
h.Div({"class": "bar-fill"}, ds.style("width", "$pct + '%'"))# <div class="bar-fill" data-style:width="$pct + '%'"></div>styles(mapping)
Set several inline style properties at once from reactive expressions.
Official Datastar docs: <https://data-star.dev/reference/attributes#data-style>
h.Div(ds.styles({"opacity": "$visible ? '1' : '0'"}))# <div data-style="{'opacity':$visible ? '1' : '0'}"></div>text(expression)
Bind an element's text content to a Datastar expression.
Official Datastar docs: <https://data-star.dev/reference/attributes#data-text>
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() or by checking w.closing inside a loop (Writer lifecycle).
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>