Skip to content
Guides contents

GuidesUI & local state

Search & typeahead

Build live search with escaped filters, available local data, and prompt cleanup of abandoned queries.

View as Markdown

A search box creates a different query as its term changes. The local engine can show matches from rows it already holds. A named server query supplies matches outside that local data. Offline search only covers available local rows.

This recipe extends the synced-app quickstart. It uses that app’s q builder, issue table, API server, and Rindle provider. Its required issue columns are id, title, status, and updatedAt.

Define and validate the query

% and _ are wildcards in like and ilike patterns. Escape these characters so a search for 100% treats the percent sign literally. Put the helper beside the query so both tiers use the same pattern.

// shared/search.ts
import { defineQuery, ilike } from "@rindle/client";
import { z } from "zod";
import { q } from "./app-def.ts";

export const MAX_SEARCH_LENGTH = 100;

export function escapeLike(value: string): string {
  return value.replace(/[\\%_]/g, (character) => `\\${character}`);
}

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

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

In server/api.ts, import searchIssuesQuery from ../shared/search.ts. Add it to the existing registerQueries<User>([...]) array. The API server resolves the named query and validates its arguments. Input validation does not replace read authorization.

ilike performs case-insensitive pattern matching, not relevance ranking or full-text search. A contains pattern starts with % and can require a broad scan during the initial read. The 20-row limit bounds the result, not necessarily the work needed to find it. See query shapes for matching and escape semantics.

Render local results and server readiness

Create the search component:

// src/SearchBox.tsx
import { useState } from "react";
import { useQuery, useQueryStatus } from "@rindle/react";
import { MAX_SEARCH_LENGTH, searchIssuesQuery } from "../shared/search.ts";

export function SearchBox() {
  const [input, setInput] = useState("");
  const term = input.trim();

  return (
    <section>
      <label>
        Search issues
        <input
          value={input}
          maxLength={MAX_SEARCH_LENGTH}
          onChange={(event) => setInput(event.target.value)}
        />
      </label>
      {term.length > 0 && <SearchResults term={term} />}
    </section>
  );
}

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

  return (
    <div aria-busy={status !== "complete"}>
      <ul>{rows.map((row) => <li key={row.id}>{row.title}</li>)}</ul>
      {status !== "complete" && <p>Searching…</p>}
      {status === "complete" && rows.length === 0 && <p>No matches.</p>}
    </div>
  );
}

Render <SearchBox /> under the app’s existing provider. An empty or whitespace-only term does not mount SearchResults. This keeps the hooks unconditional and avoids requesting every row with ilike("%%").

Visible matches can include optimistic writes and retained local rows. The loading message remains until the server confirms this term’s coverage. Only a complete empty result displays “No matches.”

releaseDelayMs: 0 releases abandoned terms without the default two-second retention period. Both hooks use it because each hook retains the query. Another reader’s later retention deadline can still keep a shared query alive. See query retention for the full lifecycle.

Search only the local rows

An unnamed builder query does not request additional server coverage. This component searches the issue rows already available in the same provider’s store:

// src/LocalIssueSearch.tsx
import { ilike } from "@rindle/client";
import { useQuery } from "@rindle/react";
import { q } from "../shared/app-def.ts";
import { escapeLike } from "../shared/search.ts";

export function LocalIssueSearch({ term }: { term: string }) {
  const rows = useQuery(
    q.issue
      .where.title(ilike(`%${escapeLike(term)}%`))
      .select("id", "title")
      .orderBy("title", "asc")
      .orderBy("id", "asc")
      .limit(20),
    { releaseDelayMs: 0 },
  );
  return <ul>{rows.map((row) => <li key={row.id}>{row.title}</li>)}</ul>;
}

Use this component for a filter over data that another query already retains. An empty local result does not prove that no matching issue exists on the server. Preloads can retain a known query, but they do not load an entire table automatically.

Reduce requests with a timed debounce

Every distinct named-query term can open a server subscription. To reduce short-lived terms, wait for a pause in typing before mounting the next result query. The input itself still updates on each keystroke.

This optional component uses the same SearchResults:

// src/DebouncedSearchBox.tsx
import { useEffect, useState } from "react";
import { MAX_SEARCH_LENGTH } from "../shared/search.ts";
import { SearchResults } from "./SearchBox.tsx";

export function DebouncedSearchBox() {
  const [input, setInput] = useState("");
  const [term, setTerm] = useState("");

  useEffect(() => {
    const timer = setTimeout(() => setTerm(input.trim()), 150);
    return () => clearTimeout(timer);
  }, [input]);

  return (
    <section>
      <label>
        Search issues
        <input
          value={input}
          maxLength={MAX_SEARCH_LENGTH}
          onChange={(event) => setInput(event.target.value)}
        />
      </label>
      {input.trim() !== term && <p>Waiting for typing to pause…</p>}
      {input.trim().length > 0 && term.length > 0 && (
        <>
          <p>Results for: {term}</p>
          <SearchResults term={term} />
        </>
      )}
    </section>
  );
}

A debounce reduces requests during a burst. It is not a server rate limit. React’s useDeferredValue schedules rendering and does not guarantee fewer network requests.