Skip to content

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 name todos is the injected facade; in the template, it is the row list your context() returns. The Protocol gives the handlers type checking.
  • add contains await, so it is a server command. That single fact decides the transport: the compiler emits a POST dispatch for it, and could not have compiled the body to the client even by mistake (see Sites & the seam).
  • filter is a plain <a href="../?filter=…"> link. Url state is the address bar: shareable and reload-surviving, rendered by the server. The trade-off is disclosed: navigating resets in-progress Local state — 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):

app.py (abridged from demos/todos)
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/adddraft 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 named Local signals to their declared defaults (how add clears the input). The reset rides the X-Siting-Signals response 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 /window route for the :virtual binder — 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.