Skip to content

5. Commands

Goal: Close and Reopen buttons that mutate the store — the client/server seam in motion.

Handlers that await

components/tracker.py
from __future__ import annotations

from typing import TYPE_CHECKING

from situ import Local, Server, Url

if TYPE_CHECKING:
    from typing import Protocol

    class IssuesFacade(Protocol):
        async def set_status(self, issue_id: int, status: str) -> None: ...


search: Local[str] = ""
selected_id: Local[int] = 0
filter: Url[str] = "all"
issues: Server[IssuesFacade]


async def close(id):
    await issues.set_status(id, "closed")


async def reopen(id):
    await issues.set_status(id, "open")

Two changes. The handlers are the feature: each body contains await, and that single fact classifies it as a server command — the compiler emits a POST dispatch for the client half and leaves the body to run on the server, where issues is the injected store. There is no decorator to remember, and the seam guarantees the reverse: a body that awaits the database cannot compile to the browser.

The Protocol upgrade (Server[object]Server[IssuesFacade]) is for your type checker: handler calls like issues.set_status(...) are now checked against a declared contract. The TYPE_CHECKING guard keeps it out of the runtime entirely.

Buttons in the detail pane

components/tracker.html (inside the detail block)
<div data-id="{{ t.id }}" :show="selected_id == t.id">
  <h2>{{ t.title }}</h2>
  <p>#{{ t.id }} · {{ t.status }}</p>
  {% if t.status == 'open' %}
  <button @click="close(t.id)">Close</button>
  {% else %}
  <button @click="reopen(t.id)">Reopen</button>
  {% endif %}
</div>

@click="close(t.id)" calls a server handler, so it compiles to a command dispatch; the positional argument becomes a path segment. Check the derived routes on this step:

POST /cmd/close/{id}
POST /cmd/reopen/{id}

Watch one command happen

Restart, select an open issue, open the network tab, click Close:

  1. One POST /cmd/close/2 leaves (with ?filter=… riding along, from part 3). While it is in flight the button is disabled and the region marked aria-busy — no double-clicks.
  2. The server runs close, context() re-runs against the mutated store, and the response is the region's fresh HTML plus nothing else. No page reload.
  3. The shim swaps the region and re-binds its binders.

Now the part worth staring at: your selection survived. The signal store lives at page scope; the swap replaced DOM inside the region, the re-bound :show re-evaluated against the still-set selected_id, and the same issue's detail is open — showing closed and a Reopen button, because the region rendered from fresh server state (the Jinja {% if %} picked the other button). The search box in <header> never flinched either; that is why it lives there.

If a command fails (kill the server and click), the response is discarded, the console logs it, and the page stays intact.

Next: Part 6 — the create dialog, which completes the app.