Search and Filter
elements man recipes/search-filter Read as markdownA 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
Terminalelements 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 trigger productsTouchUpdatedAt before update on products for each row execute function touchUpdatedAt();
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
Terminalelements 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 template.ehtml. The route
imports them from there, 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/template.ehtml, above the markup:
app/pages/search/template.ehtmlimport "./style.css"; 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. To drive the filter options from
the database, load them with a select distinct category from products query
or from a dedicated categories table; see "Categories from the database"
below.
app/pages/search/index.tsimport { Request, Response } from "@elements/app"; import search, { searchProducts, CATEGORIES } from "./template"; export default function route(req: Request, res: Response) { 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.
The rest of app/pages/search/template.ehtml, under the code above:
app/pages/search/template.ehtmllet 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="page-search" ( initial: Product[], categories: ProductCategory[], private form: SearchQuery = { text: "", categories: [], }, private results: { value: Product[] } = { value: initial }, )> <main class="page-shell"> <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> </main> </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:
index.tsimport search from "#app/pages/search"; // ... app.route("/search", search);
Notes
- Debounce on the browser. The 300 ms gate sends one RPC call per pause. For a public search endpoint, 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 returns the first 50 matches. To page through
longer result sets, use a keyset cursor as
elements man recipes infinite-scroll-feeddoes: pass the last row'snameandidto the RPC, addand (name, id) > (${lastName}, ${lastId}), order byname, id, and call for the next page from an intersection-observer sentinel. - Sort options. Add
sort: "name" | "price-asc" | "price-desc"toSearchQueryand switch theorder byclause inside the rpc. The template renders a<select value={form.sort}>next to the checkboxes; the sameonChangedebounce wraps it. - Categories from the database. Replace the hardcoded
CATEGORIESconstant 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 alongsideinitial`. - Full-text search.
ilike '%query%'matches substrings. For language-aware search, replace thewhere name ilike ${pattern}clause with atsvectorcolumn (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)), andwhere searchVector @@ to_tsquery('english', ${tsquery}). That brings stemming, multi-word AND/OR queries, and rank-ordered results.