Manual Recipes Search and Filter

Search and Filter

elements man recipes/search-filter Read as markdown

A product search page with a text query plus a multi-select category filter. As the user types or toggles a checkbox, a debounced @rpc runs server-side and returns the matching rows. The page state is one SearchQuery object bound to the inputs; the result list reassigns on every rpc return.

No LiveTable here. The search/filter pattern is request-response, not broadcast-driven: each query is its own snapshot, and the results aren't shared across browsers. An @rpc plus a reactive form is the right shape.

Migration

elements create migration 'add products' -tables=products

app/migrations/<timestamp>-add-products.migration.sql:

-- add products

-- Auto-update updatedAt on row changes.
create or replace function touchUpdatedAt()
returns trigger
language plpgsql
as $$
begin
  new.updatedAt = now();
  return new;
end;
$$;

create type productCategory as enum ('books', 'movies', 'music');

create table products (
  id uuid primary key default uuidGenerateV7(),
  createdAt timestamptz not null default now(),
  updatedAt timestamptz not null default now(),
  name text not null,
  category productCategory not null,
  priceCents integer not null
);

create index productsCategoryIdx on products (category);
create index productsNameIdx on products (lower(name));

create trigger productsTouchUpdatedAt
  before update on products
  for each row execute function touchUpdatedAt();

productsNameIdx indexes the lowercased name so the case-insensitive ilike lookup stays fast. productsCategoryIdx backs the category filter. The productCategory Postgres enum restricts the column to 'books', 'movies', and 'music'; the database rejects any other value at insert or update time. To add a category later, run alter type productCategory add value '<name>' in a follow-up migration.

Page setup

elements create page search

The product types, the available category set, and the search rpc are used only by the /search page, so they live in the page's own services.ts. The route runs the empty-query search and passes the initial result set to the template; the template debounces input events into one rpc call per pause.

app/pages/search/services.ts:

import { sql } from "@elements/app";

export type ProductCategory = "books" | "movies" | "music";

export interface Product {
  id: string;
  name: string;
  category: ProductCategory;
  priceCents: number;
}

export interface SearchQuery {
  text: string;
  categories: ProductCategory[];
}

export const CATEGORIES: ProductCategory[] = ["books", "movies", "music"];

/** @rpc */
export function searchProducts(query: SearchQuery): Product[] {
  let pattern = `%${query.text.trim()}%`;

  if (query.categories.length > 0) {
    return sql<Product>(
      `select id, name, category, priceCents from products
       where name ilike ${pattern}
         and category = any(${query.categories})
       order by name asc
       limit 50`,
    ).all();
  }

  return sql<Product>(
    `select id, name, category, priceCents from products
     where name ilike ${pattern}
     order by name asc
     limit 50`,
  ).all();
}

pattern defaults to '%%' when the user types nothing, which matches every name. The two branches collapse the "filter is empty" case to a query without the any clause; Postgres treats category = any(empty array) as always false, so the explicit branch is the simplest correct form.

ProductCategory mirrors the Postgres enum values; the union type narrows Product.category and the CATEGORIES array. In a real app the filter options would come from the database (a select distinct category from products query, or a dedicated categories table), with the union type generated from the schema.

app/pages/search/index.ts:

import search from "./template";
import { searchProducts, CATEGORIES } from "./services";

export default function route(req, res) {
  let initial = searchProducts({ text: "", categories: [] });

  return new search({ initial, categories: CATEGORIES });
}

The route runs the search with empty filters and passes the first 50 products as initial. The page server-renders with results already visible, no fetching spinner on mount.

app/pages/search/template.html:

import "./style.css";
import { Product, ProductCategory, SearchQuery, searchProducts } from "./services";

let debounceTimer: ReturnType<typeof setTimeout> | null = null;

function onChange(form: SearchQuery, results: { value: Product[] }) {
  if (debounceTimer !== null) {
    clearTimeout(debounceTimer);
  }

  debounceTimer = setTimeout(() => {
    results.value = searchProducts({
      text: form.text,
      categories: form.categories.slice(),
    });
  }, 300);
}

<html class="search"
      (initial: Product[],
       categories: ProductCategory[],
       private form: SearchQuery = { text: "", categories: [] },
       private results: { value: Product[] } = { value: initial })>
  <h1>products</h1>

  <form class="filters">
    <input type="text"
           value={form.text}
           placeholder="search by name"
           oninput={() => onChange(form, results)}>

    <fieldset class="categories">
      <legend>category</legend>
      <label e:for={cat of categories}>
        <input type="checkbox"
               group={form.categories}
               value={cat}
               onchange={() => onChange(form, results)}>
        {cat}
      </label>
    </fieldset>
  </form>

  <ul class="results">
    <li e:for={p of results.value}>
      <span class="name">{p.name}</span>
      <span class="category">{p.category}</span>
      <span class="price">${(p.priceCents / 100).toFixed(2)}</span>
    </li>
    <li e:if={results.value.length === 0}>no matches.</li>
  </ul>
</html>

The text input binds to form.text with value. Each checkbox binds to form.categories via the group attribute: every checked checkbox's value is included in the array, and toggling a box updates the array in place. Both inputs call onChange, which debounces a single rpc call across them.

The debounce timer lives at module scope so it survives between event firings without ending up as template state. clearTimeout cancels the pending call when a new event arrives within 300 ms, so a fast typist hits the rpc once per pause rather than once per keystroke.

form.categories.slice() makes a copy at call time. The array reference inside the template instance keeps being mutated by the group binding; sending a snapshot stops the rpc from accidentally seeing later edits if a second toggle lands while the round-trip is in flight.

Routes

Register the page in index.ts:

import search from "#app/pages/search";

// ...
app.route("/search", search);

Notes

  • Debounce on the browser, not on the server. The 300 ms gate stops a flood of rpc calls. The server-side rpc itself does no rate limiting and assumes each call is intentional. For abuse-resistant search at scale, add request limiting at the app level.
  • Server-side initial render. Running the empty-query search inside the route handler means the first paint shows results. The page isn't a blank state that fills in via rpc on mount.
  • Pagination. This recipe caps at limit 50. To paginate longer result sets, layer the pattern from elements man recipes infinite-scroll-feed: add an offset argument to the rpc and an intersection-observer sentinel that calls for the next page.
  • Sort options. Add sort: "name" | "price-asc" | "price-desc" to SearchQuery and switch the order by clause inside the rpc. The template renders a <select value={form.sort}> next to the checkboxes; the same onChange debounce wraps it.
  • Categories from the database. Replace the hardcoded CATEGORIES constant with a route-side load: let categories = sql<{ category: ProductCategory }>(\select distinct category from products order by category`).all().map(r => r.category);. Pass the result to the template alongside initial`.
  • Full-text search. ilike '%query%' is the simplest form and the right choice for this recipe's focus. For production search, replace the where name ilike ${pattern} clause with a tsvector column (alter table products add column searchVector tsvector generated always as (to_tsvector('english', name)) stored), a GIN index (create index productsSearchIdx on products using gin (searchVector)), and where searchVector @@ to_tsquery('english', ${tsquery}). That brings stemming, multi-word AND/OR queries, and rank-ordered results.