6. The create dialog¶
Goal: a New-issue dialog — a form that POSTs a command, clears itself, and closes. This part completes the app.
The final component¶
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 IssuesFacade(Protocol):
"""The server-sited operations the handlers call (impl: IssueStore)."""
async def create(self, title: str) -> None: ...
async def set_status(self, issue_id: int, status: str) -> None: ...
reset: Callable[..., None] # injected at exec: clears local signals to their defaults
search: Local[str] = "" # the live-search box — client-only
selected_id: Local[int] = 0 # which row's detail is open (0 = none)
dialog_open: Local[bool] = False
new_title: Local[str] = ""
filter: Url[str] = "all" # all | open | closed — a shareable link
issues: Server[IssuesFacade] # the store: a facade in handlers, a row list in the template
async def create(new_title):
await issues.create(new_title)
reset("new_title", "dialog_open") # clear the input, close the dialog
async def close(id):
await issues.set_status(id, "closed")
async def reopen(id):
await issues.set_status(id, "open")
<section class="tracker">
<header>
<input :bind="search" placeholder="Search title…" autocomplete="off">
<button class="primary" @click="dialog_open = True">New issue</button>
<div class="dialog" :show="dialog_open">
<form @submit="create">
<input :bind="new_title" name="new_title" placeholder="Title" autocomplete="off">
<button class="primary">Create</button>
<button type="button" @click="dialog_open = False">Cancel</button>
</form>
</div>
</header>
<div data-region>
<nav>
<a href="?filter=all" class="{{ 'selected' if filter == 'all' else '' }}">all</a>
<a href="?filter=open" class="{{ 'selected' if filter == 'open' else '' }}">open</a>
<a href="?filter=closed" class="{{ 'selected' if filter == 'closed' else '' }}">closed</a>
<span class="count">{{ issues | length }} shown</span>
</nav>
<ul class="issue-list">
{% for t in issues %}
<li data-id="{{ t.id }}" data-title="{{ t.title }}"
:show="search.lower() in t.title.lower()"
:class="selected: selected_id == t.id">
<button @click="selected_id = t.id">{{ t.title }}</button>
<span class="status {{ t.status }}">{{ t.status }}</span>
</li>
{% endfor %}
</ul>
<div class="detail">
{% for t in issues %}
<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>
{% endfor %}
</div>
</div>
</section>
The new pieces, and why each is where it is:
- The dialog lives in
<header>. A command's region swap would tear down and rebuild anything inside the region — an open dialog included. The header survives swaps, so overlays, search boxes, and toasts belong there. (This is the two-roots rule from the Quickstart, now earning its keep.) @submit="create"on the<form>, with the input carryingname="new_title"— a submitted form sends its fields, and the handler'snew_titleparameter binds from them. Notetype="button"on Cancel: buttons inside a form default totype=submit, and an untyped Cancel would submit the form.reset("new_title", "dialog_open")in the handler — the command's response carries a signal patch (theX-Siting-Signalsheader) that resets both signals to their defaults. The input clears and the dialog closes, with zero client code for either.
Restart: New issue → type a title → Create. One POST to /cmd/create, the list grows, the input is empty, the dialog is gone.

What you built, by the numbers¶
| Signal | Site | What it cost you |
|---|---|---|
search |
Local |
one declaration + two binders; filtering is compiled JS, zero network |
selected_id |
Local |
one declaration; selection survives command swaps for free |
dialog_open, new_title |
Local |
a dialog with self-clearing state |
filter |
Url |
three <a> tags; shareable, reload-surviving |
issues |
Server |
a store, a facade Protocol, a context() — and three handlers that became three POST routes |
Hand-written JavaScript: zero. The generated island for the finished app is ~6.8 KB; every line of it is readable at /island.js.
Where next¶
- Split it into components. The header, the list, and the detail pane want to be separate files as this grows — Composition does that at compile time, and the island stays byte-identical.
- Make it multi-client.
mount_component(..., live=True)broadcasts mutations to every connected browser — see the wire protocol. - Style it with the kit. The component kit replaces the tutorial CSS with a design system, and
<Dialog>/<Combobox>/<DataGrid>replace hand-rolled widgets. - Generate it. declui produces this app's whole shape — list, detail, gated actions — from one typed model; the capstone example is this tracker, grown up.
- Read the flagship.
demos/issuesis this same app decomposed into a five-component tree with multi-select and bulk commands — run the demos.