Recipes

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.

View as Markdown

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 — 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.

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/ilike matrix, including the escape rules.
  • Preload & navigate — keeping a corpus locally resident so the local-only form answers everything.
  • Authorizing reads — the authorizeQuery hook every search lease still passes through.