2. Live search¶
Goal: a search box that filters rows as you type — with zero network traffic.
Declare a client signal¶
<section class="tracker">
<header>
<input :bind="search" placeholder="Search title…" autocomplete="off">
</header>
<div data-region>
<p class="count">{{ issues | length }} shown</p>
<ul class="issue-list">
{% for t in issues %}
<li data-row="IssueRow" data-id="{{ t.id }}" data-title="{{ t.title }}"
:show="search.lower() in t.title.lower()">
<span>{{ t.title }}</span>
<span class="status {{ t.status }}">{{ t.status }}</span>
</li>
{% endfor %}
</ul>
</div>
</section>
search is Local: the browser owns it. Two binders wire it up:
:bind="search"on the input — two-way: typing writes the signal, and a signal write repaints the input (with a focus guard, so nothing clobbers you mid-keystroke).:show="search.lower() in t.title.lower()"on each row — a compiled predicate that re-evaluates wheneversearchchanges.incompiles to.includes,.lower()to.toLowerCase().
The row expression reads t.title — a server row's field, on the client. That works through the projection you wrote yourself: data-title="{{ t.title }}" puts the field on the row element, and the compiler reads t.title from the nearest data-id ancestor's dataset at runtime. You choose, field by field, what a row exposes to the browser.
data-row="IssueRow" is the other half, and it is required whenever the client reads a row: it names the TypedDict that says what those fields are. A data-* attribute is always a string — data-id="3" is "3", not 3 — so without a declared type the compiler would have to guess at the coercion, and it used to. Now t.id is decoded to an int because you said it is one, and t.title needs no decoding because a str already is one. Read a field the row does not carry, or one the record does not name, and you get a compile error rather than undefined in the browser.
Restart and type. Open the network tab first: filtering triggers zero requests — the predicate lives in the island.
Also worth noticing: the input sits in <header>, outside the region. Both roots are wired at boot, and the header survives server re-renders — which starts to matter in part 5.
Meet the seam¶
Try to shortcut the count. Replace the <p class="count">…</p> line with:
Restart, reload, and the compiler stops you:
CompileError: server-sited 'issues' cannot be read on the client;
DB-backed state stays on the server
issues lives on the server; a client binder cannot read it — by construction, with no exceptions to remember. Server data reaches the browser three ways only: rendered by Jinja, projected per row through data-* attributes (as t.title above), or pushed into a client signal by a command. Put the Jinja count back and move on.
If rows refuse to hide
:show sets the hidden attribute, and any author display: on the same element overrides the browser's default [hidden] rule. The stylesheet from the setup guards this with [hidden] { display: none !important; } — keep that habit in your own apps.
Next: Part 3 — shareable filters, a signal that lives in the address bar.