# Search and Filter 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. Filtering rows the page already holds is a different job with a different primitive. A LiveTable filters itself: `todos.filter(predicate)` returns a live view, so tabs like All / Active / Done are one predicate in the `e:for`, no rpc and no css hiding. That pattern is the Views section of `elements man livetable/options`, worked in `elements man recipes/todo-app`. This recipe has no LiveTable because it searches rows the browser never loads. Each query is its own server-side snapshot, and the results aren't shared across browsers. An `@rpc` plus a reactive form is the right shape. ## Migration ```bash elements create migration 'add products' -tables=products ``` `app/migrations/-add-products.migration.sql`: ```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 ''` in a follow-up migration. ## Page setup ```bash 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`: ```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( `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( `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`: ```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`: ```html import "./style.css"; import { Product, ProductCategory, SearchQuery, searchProducts, } from "./services"; let debounceTimer: ReturnType | 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); }

products

onChange(form, results)}>
category
  • {p.name} {p.category} ${(p.priceCents / 100).toFixed(2)}
  • no matches.
``` 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`: ```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 `