# Search & typeahead

Search-as-you-type is a new query per keystroke — the one shape where Rindle's defaults need adjusting. Filter with `ilike` (escaped), resolve locally over rows you already sync for instant offline results, and pass `releaseDelayMs: 0` so each abandoned keystroke's query tears down instead of holding a dead view and an open subscription.

Typeahead inverts the usual query lifecycle. Most queries are long-lived — a list you watch, a
detail you return to. A search box produces the opposite: `sea`, `sear`, `searc`, `search` are four
**different** queries, each alive for one keystroke and never seen again. Two Rindle defaults are
tuned for the long-lived case, and this recipe adjusts both. Results resolve **locally first** (so
they're instant and work offline), and each abandoned query **tears down immediately** instead of
idling in the 2-second warm window.

## The query: `ilike`, escaped

`like` / `ilike` are ordinary [query shapes](/docs/supported-queries-ts) — incrementally
maintained, identical semantics on the client engine and SQLite. The one thing to get right is
escaping: `%` and `_` are wildcards, and a user typing `100%` doesn't mean "everything". Put the
escape helper next to the query so both tiers build the identical condition:

```ts
// src/components/Search.queries.ts
import { defineQuery, ilike } from "@rindle/client";
import { z } from "zod";
import { q } from "../../shared/app-def.ts";

const searchArgs = z.object({ term: z.string().min(1).max(100) });

// \ escapes %, _, and itself — the builder supports `\%` / `\_` / `\\`.
const escapeLike = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`);

export const searchIssues = defineQuery("searchIssues", (raw) => searchArgs.parse(raw), ({ term }) =>
  q.issue
    .where.title(ilike(`%${escapeLike(term)}%`))
    .select("id", "title", "status")
    .orderBy("updatedAt", "desc")
    .limit(20),
);
```

Because a named query ships only `(name, args)` up the wire and the server resolves the AST from
its own registry, the arg schema is your input validation. The `max(100)` bounds the pattern an
anonymous user can make the server maintain.

## The component: `releaseDelayMs: 0`

When a query's last reader unmounts, Rindle keeps the view and its server subscription warm for
**2 seconds** — the grace window that makes navigation and filter changes feel instant. For
typeahead that default is exactly wrong: every keystroke abandons a query you will *never* return
to. The window then leaves one dead view and one open subscription per character typed. The query is
truly ephemeral — ask for no warm window at all:

```tsx
import { useQuery, useQueryStatus } from "@rindle/react";
import { searchIssues } from "./Search.queries.ts";

export function SearchBox() {
  const [term, setTerm] = useState("");
  return (
    <div className="search">
      <input value={term} onChange={(e) => setTerm(e.target.value)} placeholder="Search…" />
      {term.length > 0 && <SearchResults term={term} />}
    </div>
  );
}

function SearchResults({ term }: { term: string }) {
  const query = searchIssues({ term });
  const rows = useQuery(query, { releaseDelayMs: 0 });
  const status = useQueryStatus(query, { releaseDelayMs: 0 });

  if (rows.length === 0) {
    return <p className="muted">{status === "unknown" ? "Searching…" : "No matches."}</p>;
  }
  return <ul>{rows.map((r) => <li key={r.id}>{r.title}</li>)}</ul>;
}
```

Three details doing quiet work:

- **The empty term is handled by not mounting**, not by a degenerate query — `SearchBox` renders
  `SearchResults` only when there's something to search, so no hook runs conditionally and no
  `ilike("%%")` full-table query ever exists.
- **Results are optimistic-first.** The local engine answers each keystroke from rows it already
  holds *immediately*. The server lease then adds anything you hadn't synced. So matches you can
  already see never wait on the network — which is why there's no debounce on the render path.
- **`status === "unknown"` distinguishes "still asking the server" from "genuinely no matches"**,
  so the empty state doesn't flash "No matches" before the first server answer.

`releaseDelayMs` is a *deadline, not a duration*. A shared query stays warm until the latest
deadline any reader asked for, so your `0` never tears down a query some other component still
wants warm. The full semantics are in [the client doc](/docs/client).

## Searching locally only

If the corpus you're searching is already synced — a command palette over the project list you
preloaded, a filter box above a list that's already on screen — you don't need a server query at
all. An ad-hoc builder query resolves **locally only** and never opens a server subscription:

```tsx
import { ilike } from "@rindle/client";
import { app } from "../rindle-client.ts";

function PaletteResults({ term }: { term: string }) {
  const rows = useQuery(
    app.store.query.project
      .where.name(ilike(`%${escapeLike(term)}%`))
      .orderBy("name", "asc")
      .limit(10),
    { releaseDelayMs: 0 },
  );
  return <ul>{rows.map((r) => <li key={r.id}>{r.name}</li>)}</ul>;
}
```

This is instant, free of server cost per keystroke, and works offline — but it only sees rows your
other queries have synced. When search must cover data the client hasn't synced, use the
named-query form. Keep [preloads](/docs/preloads) warm for the corpora you want palette-fast.

## Trimming server chatter

The local render path needs no debounce, but with the named-query form each keystroke still opens
(and, at `releaseDelayMs: 0`, promptly closes) a server lease. If that churn matters, debounce the
**term you hand the named query** — not the input, not the render:

```tsx
const deferredTerm = useDeferredValue(term);   // or a ~150ms debounce
{term.length > 0 && <SearchResults term={deferredTerm.length > 0 ? deferredTerm : term} />}
```

A hybrid also works well: an ad-hoc local query on the raw term for instant matches, plus the named
server query on the debounced term to fetch the long tail.

## See also

- [The browser client](/docs/client) — the warm-window deadline semantics, `ResultType`, and local
  query resolution this recipe relies on.
- [Query shapes](/docs/supported-queries-ts) — the full `like`/`ilike` matrix, including the
  escape rules.
- [Preload & navigate](/docs/preloads) — keeping a corpus locally resident so the local-only form
  answers everything.
- [Authorizing reads](/docs/authorization) — the `authorizeQuery` hook every search lease still
  passes through.

---

[View this page on Rindle](https://rindle.sh/docs/typeahead)
