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 — 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:
// 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:
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 —
SearchBoxrendersSearchResultsonly when there’s something to search, so no hook runs conditionally and noilike("%%")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.
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:
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 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:
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 — the warm-window deadline semantics,
ResultType, and local query resolution this recipe relies on. - Query shapes — the full
like/ilikematrix, including the escape rules. - Preload & navigate — keeping a corpus locally resident so the local-only form answers everything.
- Authorizing reads — the
authorizeQueryhook every search lease still passes through.