Skip to content

2. Live search

Goal: a search box that filters rows as you type — with zero network traffic.

Declare a client signal

from __future__ import annotations

from situ import Local, Server

search: Local[str] = ""
issues: Server[object]
<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-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 whenever search changes. in compiles 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.

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:

<p class="count" :text="issues"></p>

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.