Server components¶
The quickstart's counter ran entirely in the browser. This walkthrough adds the database — in situ that means declaring one signal's site as Server[...], and the rest of the component keeps its shape. The example is the demos/todos app: three sites in one component.
1. Declare the sites¶
from __future__ import annotations
from collections.abc import Callable
from typing import TYPE_CHECKING
from situ import Local, Server, Url
if TYPE_CHECKING:
from typing import Protocol
class TodosFacade(Protocol):
"""The server-sited ops the handlers call."""
async def create(self, title: str) -> None: ...
async def toggle(self, todo_id: int) -> None: ...
async def delete(self, todo_id: int) -> None: ...
reset: Callable[..., None] # injected at exec: clears local signals to their defaults
draft: Local[str] = "" # the new-todo input; client-only until submitted
filter: Url[str] = "all" # shareable, survives reload
todos: Server[TodosFacade] # server resource: a facade in handlers, a row list in the template
async def add(draft):
await todos.create(draft)
reset("draft") # clear the input after adding
async def toggle(id):
await todos.toggle(id)
<div data-region>
<form @submit="add">
<input class="new-todo" :bind="draft" name="draft" placeholder="What needs doing?">
</form>
<ul class="todo-list">
{% for t in todos %}
<li class="todo {{ 'completed' if t.done else '' }}" data-id="{{ t.id }}">
<button class="toggle" @click="toggle(t.id)">{{ "✓" if t.done else "○" }}</button>
<span class="todo-label">{{ t.title }}</span>
</li>
{% endfor %}
</ul>
<ul class="filters">
<li><a href="?filter=active" class="{{ 'selected' if filter == 'active' else '' }}">active</a></li>
</ul>
</div>
Three things to notice:
todos: Server[TodosFacade]— inside handlers, the bare nametodosis the injected facade; in the template, it is the row list yourcontext()returns. TheProtocolgives the handlers type checking.addcontainsawait, so it is a server command. That single fact decides the transport: the compiler emits aPOSTdispatch for it, and could not have compiled the body to the client even by mistake (see Sites & the seam).filteris a plain<a href="../?filter=…">link.Urlstate is the address bar: shareable and reload-surviving, rendered by the server. The trade-off is disclosed: navigating resets in-progressLocalstate — that's why the site is your choice per signal.
2. Provide the service and the context¶
The Litestar mount_component resolves your service per request from a Dishka container, then calls your context() to build what the template renders (the Flask adapter resolves the same service via a plain resolve callable instead — see the Flask example):
from dishka import Provider, Scope, provide
from situ import mount_component
class TodoProvider(Provider):
scope = Scope.APP
@provide
def store(self) -> TodoStore:
return TodoStore() # one in-memory store shared by every request
async def context(service: TodoStore, view: Mapping[str, str]) -> Mapping[str, object]:
"""(service, url-view) -> the dict the template renders."""
which = view.get("filter", "all")
return {"todos": service.visible(which), "filter": which}
router = mount_component(
path="/todos",
stem=HERE / "components" / "todos",
template="todos/templates/index.html",
meta=META,
service_type=TodoStore, # what to resolve from the Dishka container
context=context, # fresh template data on every render
)
view is the current Url-sited signals, read from the query string with their declared defaults.
3. What the mount derived¶
For the component above, with zero further input:
| Handler | Classified | Derived route |
|---|---|---|
add |
server command | POST /todos/cmd/add — draft rides along as a form field |
toggle |
server command | POST /todos/cmd/toggle/{id} — the call's positional arg became a path segment |
delete |
server command | POST /todos/cmd/delete/{id} |
and the command round trip works like this:
sequenceDiagram
participant I as Island (browser)
participant M as Mount (Litestar / Flask)
participant F as Facade (from resolve/DI)
I->>M: POST /todos/cmd/add (X-Siting: 1, form fields + url params)
M->>F: exec component .py — await todos.create(draft)
M->>M: re-render the region from fresh context()
M-->>I: 200 region HTML + X-Siting-Signals: {"draft": ""}
I->>I: swap the region · apply the signal patch · re-bind
While the POST is in flight the invoking control is disabled and the region marked aria-busy; a failed command rolls back and never swaps an error body in. Details in The wire protocol.
Pushing values back: reset and signal¶
Two callables are injected into your component's namespace for server handlers:
reset("draft", ...)— reset namedLocalsignals to their declared defaults (howaddclears the input). The reset rides theX-Siting-Signalsresponse header.signal("name", value)— push an explicit value into a client signal alongside the re-rendered region.
Going further¶
- Live, multi-client:
mount_component(..., live=True)adds an SSE/feed— a command on one client makes every connected peer re-fetch its region. See the poll demo and the wire protocol. - Huge lists:
window_template=adds a/windowroute for the:virtualbinder — 10,000 rows with only the visible window in the DOM (data_grid demo). - Component trees: pass
components=Context.from_dir(...)to compose the page from child components — see Composition.