Skip to content

5. Commands

This part adds Close and Reopen buttons that mutate the store, putting 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 things change. The handlers are the feature: each body contains await. 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. 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-row="IssueRow" 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 the app, select an open issue, open the network tab, and 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; a second click does nothing.
  2. The server runs close, then context() re-runs against the mutated store. 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 look at the part that matters: your selection survived. The signal store lives at page scope. The swap replaced DOM inside the region only. The re-bound :show re-evaluated against the still-set selected_id, so the same issue's detail is open, showing closed and a Reopen button: the region rendered from fresh server state, so the Jinja {% if %} picked the other button. The search box in <header> never flinched; that is why it lives there.

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

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