Datastar ¶
Stario is centered around seamless integration with Datastar - a real-time hypermedia framework that combines frontend and backend reactivity together.
This integration has three main parts:
- Reading Signals - Stario provides set of dependencies that allow to read and work with them.
- Building Attributes & Actions - Building
data-*attributes for HTML elements - Updating HTML & Signals - Patching HTML elements or signals in response to user actions.
To get the most out of this page you should already be somewhat familiar with Datastar documentation.
Reading Signal Values ¶
When Datastar makes HTTP request, (by default) it includes all defined signals and their values.
In GET requests they're sent as query parameters, in other methods (POST, PUT, etc.) they're in the request body.
All requests originating from Datastar include additional header datastar-request = true that might be used to distinguish them from other requests.
In Stario we provide a set of dependencies that provide access to signals and their values.
Reading All Signals ¶
from stario.datastar import Signals
async def handler(signals: Signals):
# signals is a dictionary of all signal values
# {"counter": 42, "message": "Hello", "user": {"name": "Alice"}}
return div(f"Received {len(signals)} signals")
Reading Specific Signals ¶
from stario.datastar import Signal
async def increment(counter: Signal[int]):
# Reads the 'counter' signal, parsed as int
new_value = counter + 1
return div(f"Counter: {new_value}")
async def greet(name: Signal[str], age: Signal[int]):
# Multiple signals
return div(f"Hello {name}, you are {age} years old")
Nested Signals ¶
For nested signals like user.name or counters.clicks, use double underscores (__) in the parameter name:
async def handler(
user__name: Signal[str], # Reads 'user.name'
counters__clicks: Signal[int], # Reads 'counters.clicks'
):
return div(f"{user__name} has {counters__clicks} clicks")
Custom Signal Names ¶
from stario.datastar import ParseSignal
from typing import Annotated
async def handler(
flat: Annotated[str, ParseSignal("custom_flat")], # Reads: 'custom_flat' signal
nested: Annotated[str, ParseSignal("some.deeply.nested.signal")], # Reads: 'some.deeply.nested.signal' signal
):
return div(f"Value: {flat} {nested}")
Signal Defaults & Missing Values ¶
When a signal is not provided in the request, you can provide a default value:
from stario.datastar import Signal
from stario.html import div
# With default - no error if signal is missing
async def handler(
username: Signal[str] = "anonymous", # Falls back to "anonymous"
count: Signal[int] = 0, # Falls back to 0
):
return div(f"{username} has {count} items")
async def handler(
user_id: Signal[int] # Required! Error if not provided
):
# This will return with 400 Bad Request if 'user_id' signal is not in request
# or 422 Unprocessable Entity if 'user_id' signal is present but cannot be parsed as int
return div(f"User: {user_id}")
Error handling: When a required signal is missing or fails to parse, Stario automatically returns a 400 Bad Request or 422 Unprocessable Entity response to the client.
Attributes & Actions ¶
Stario splits Datastar building into two focused dependencies:
Attributes- Generate HTMLdata-*attributes (no dependencies)Actions- Generate Datastar actions (requires Stario app for URL resolution)
Getting the Dependencies ¶
from stario.datastar import Attributes, Actions
from stario.html import div, button, input_
async def my_component(attr: Attributes, act: Actions):
return div(
attr.signals({"count": 0}),
button(
attr.on("click", "$count++"),
"Increment"
),
div(attr.text("$count")),
button(
attr.on("click", act.post("save")),
"Save"
),
)
Attributes ¶
Attributes generate data-* properties for reactive DOM binding and state management. They don't depend on the Stario app and can be used independently.
signals(signals_dict, *, ifmissing=False)
Initialize signals with values:
attr.signals({"count": 0, "message": "Hello"})
# {'data-signals': '{"count":"0","message":"Hello"}'}
attr.signals({"fallback": "default"}, ifmissing=True)
# {'data-signals__ifmissing': '{"fallback":"default"}'}
bind(signal_name)
Two-way data binding between a signal and element's value:
input_({"type": "text"}, attr.bind("username"))
# Syncs input value ↔ $username signal
text(expression)
Set element's text content from an expression:
span(attr.text("$count"))
# Updates text content reactively when $count changes
show(expression)
Show/hide element based on boolean expression:
div(attr.show("$isVisible"), "Conditional content")
# Displayed when $isVisible is true
on(event, expression, **modifiers)
Execute expression when event fires:
button(attr.on("click", "$count++"), "Increment")
button(attr.on("input", "$search()", debounce=0.3), "Search")
Supported events: All standard DOM events (click, input, submit, etc.) plus special events (load, intersect, interval, signal-patch).
Common modifiers:
prevent=True-event.preventDefault()stop=True-event.stopPropagation()once=True- Fire only oncedebounce=0.3- Debounce for 300msthrottle=1- Throttle to once per seconddelay=0.5- Delay execution by 500ms
class_(class_dict)
Add/remove classes based on expressions:
div(attr.class_({"hidden": "$isHidden", "active": "$isActive"}))
# Adds 'hidden' class when $isHidden is true
style(style_dict)
Set CSS styles based on expressions:
div(attr.style({"color": "$themeColor", "opacity": "$alpha"}))
attr(attr_dict)
Set arbitrary attributes from expressions:
button(attr.attr({"disabled": "$isSaving", "title": "$tooltip"}))
computed(computed_dict)
Create computed signals (read-only, auto-recalculate):
attr.computed({"fullName": "$firstName + ' ' + $lastName"})
attr.computed({"total": "$price * $quantity"})
ref(signal_name)
Store DOM element reference in a signal:
input_(attr.ref("inputEl"))
# Access later: $inputEl.focus()
indicator(signal_name)
Track fetch request status:
div(attr.indicator("loading"))
# $loading is true during fetch, false after
on_interval(expression, *, duration="1s", viewtransition=False)
Execute expression at regular intervals:
div(attr.on_interval("$refresh()"))
div(attr.on_interval("$poll()", duration=5))
div(attr.on_interval("$check()", duration=(2, "leading")))
on_intersect(expression, *, once=False, half=False, full=False, delay=None, debounce=None, throttle=None, viewtransition=False)
Execute expression when element intersects viewport:
div(attr.on_intersect("@get('/lazy-load')"))
div(attr.on_intersect("$load()", once=True, half=True))
on_signal_patch(expression, *, delay=None, debounce=None, throttle=None, include=None, exclude=None)
Execute expression when signals are patched from backend:
div(attr.on_signal_patch("console.log('updated')"))
div(attr.on_signal_patch("$log()", include="user.*", debounce=0.5))
Time Values ¶
Many modifiers accept time values:
int→ seconds with "s" suffix:5→"5s"float→ partial seconds parsed as milliseconds with "ms" suffix:0.5→"500ms"str→ passed through as-is:"2s"→"2s"
attr.on("input", "$search()", debounce=0.3) # 300ms
attr.on_interval("$refresh()", duration=5) # 5 seconds
attr.init("$init()", delay=2) # 2 seconds
Actions ¶
Actions are strings representing Datastar operations.
They're typically used in event handlers as part of the expression.
The Actions dependency requires Stario app for URL resolution.
HTTP Actions:
act.get("/api/users") # GET request
act.post("/api/users") # POST request
act.put("/api/users/123") # PUT request
act.patch("/api/users/123") # PATCH request
act.delete("/api/users/123") # DELETE request
With options:
act.post(
"/api/save",
include=["username", "email"], # Only send these signals
exclude="password", # Don't send password signal
selector="#result", # Update this element
headers={"X-Custom": "value"}, # Additional headers
)
URL Resolution:
# Leading / = absolute path
act.get("/api/users")
# No leading / = route name, resolved via url_path_for()
act.get("user_list") # Resolves to route named "user_list"
act.get("user_detail", user_id=123) # With path params
Utility Actions:
act.peek("$count") # Read signal without subscribing
act.set_all("0", include="counter.*") # Set multiple signals
act.toggle_all(exclude=["id"]) # Toggle boolean signals
Pro Actions (requires Datastar Pro):
act.clipboard("Text to copy") # Copy to clipboard
act.fit("$value", 0, 100, 0, 1) # Value interpolation
Complete Example ¶
from stario import Stario
from stario.routes import Query, Command
from stario.datastar import Attributes, Actions, Signal
from stario.html import div, button, input_, span
app = Stario()
@app.query("/counter")
async def counter_page(attr: Attributes):
return div(
attr.signals({"count": 0}),
div(
span(attr.text("$count")),
button(attr.on("click", "$count++"), "Increment"),
button(attr.on("click", "$count--"), "Decrement"),
button(attr.on("click", "$count = 0"), "Reset"),
),
)
@app.query("/search")
async def search_page(attr: Attributes, act: Actions):
return div(
attr.signals({"query": "", "results": []}),
input_(
{"type": "text", "placeholder": "Search..."},
attr.bind("query"),
attr.on("input", act.get("search_results"), debounce=0.3),
),
div(
{"id": "results"},
attr.show("$results.length > 0"),
# Results updated by server
),
)
@app.query("/search/results")
async def search_results(query: Signal[str]):
# Perform search
results = await search_database(query)
return div(
[div(result.title) for result in results]
)
Response Interpretation ¶
When your handler returns HTML or uses yield, Stario converts it to a format Datastar understands.
Regular Returns (HTML Responses) ¶
Regular returns are rendered to HTML and sent as standard HTTP responses:
async def handler(attr: Attributes):
return div(
attr.signals({"count": 0}),
"Static content"
)
# → 200 OK with rendered HTML
Streaming Responses (Generators) ¶
When your handler yields, Stario converts it to text/event-stream (Server-Sent Events) that Datastar processes.
See Endpoints - Response Processing for comprehensive documentation on streaming, including:
- Simplified yield format (strings and tuples)
- Object-based format (full control with SSE parameters)
- ElementsPatch - Morphing HTML elements
- SignalsPatch - Updating signals
- ScriptExecution - Running JavaScript
- Redirection - Navigating to new pages
Yield Formats Quick Reference ¶
Simplified (recommended for most cases):
async def handler():
# Patch element (outer mode, fat-morph)
yield div({"id": "counter"}, "Count: 42")
# Patch signals
yield {"counter": 42, "message": "Updated"}
# Execute script
yield "script", "console.log('Hello')"
# Redirect
yield "redirect", "/success"
# Patch with specific mode and selector
yield "replace", "#counter", div("42")
# Remove element
yield "remove", "#counter"
Object-based (full control):
from stario.datastar import ElementsPatch, SignalsPatch, ScriptExecution, Redirection
async def handler():
yield ElementsPatch(
mode="append",
selector="#list",
elements=li("New item"),
use_view_transition=True,
event_id="event-1",
)
yield SignalsPatch(
signals={"updated": True},
only_if_missing=False,
event_id="event-2",
)
yield ScriptExecution(
script="console.log('Done')",
auto_remove=True,
)
yield Redirection(location="/next-page")
SSE Event Stream ¶
Behind the scenes, yields become SSE events:
event: datastar-patch-elements
data: mode outer
data: selector #counter
data: elements <div>42</div>
event: datastar-patch-signals
data: signals {counter: 42}
Datastar's client-side JavaScript processes these events and updates the DOM/signals accordingly.
Merging Fragments ¶
When using outer or inner modes of element patching, datastar tried to morph the new element with the existing one.
This means that it (best effort) tries to do:
- Existing element attributes are preserved if not specified
- Event listeners remain attached
- Focus and scroll position are maintained
- Smooth, minimal DOM updates
When using other modes operations it's just modify the DOM directly so the results might be not so smooth and elegant.
From practical standpoint you should almost always just follow the The Fat Morph Strategy.
Best Practices ¶
- Initialize signals in the HTML before using them in expressions
- Use route names (not paths) in HTTP actions for type-safe URLs
- Debounce search inputs to avoid excessive requests
- Use indicators to show loading states
- Separate concerns - use
Attributesfor UI declarations,Actionsfor server interactions
Next Steps ¶
- Read Endpoints for streaming response details
- Explore the Datastar documentation for client-side features
- Learn about general Dependencies usage
- See HTML for attribute merging
