5. Commands¶
This part adds Close and Reopen buttons that mutate the store, putting the client/server seam in motion.
Handlers that await¶
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¶
<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:
Watch one command happen¶
Restart the app, select an open issue, open the network tab, and click Close:
- One
POST /cmd/close/2leaves, with?filter=…riding along from part 3. While it is in flight the button is disabled and the region markedaria-busy; a second click does nothing. - The server runs
close, thencontext()re-runs against the mutated store. The response is the region's fresh HTML plus nothing else. No page reload. - 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.