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:

  1. Reading Signals - Stario provides set of dependencies that allow to read and work with them.
  2. Building Attributes & Actions - Building data-* attributes for HTML elements
  3. 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 HTML data-* 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 once
  • debounce=0.3 - Debounce for 300ms
  • throttle=1 - Throttle to once per second
  • delay=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

  1. Initialize signals in the HTML before using them in expressions
  2. Use route names (not paths) in HTTP actions for type-safe URLs
  3. Debounce search inputs to avoid excessive requests
  4. Use indicators to show loading states
  5. Separate concerns - use Attributes for UI declarations, Actions for server interactions

Next Steps