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.
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 fromelements man recipes infinite-scroll-feed: add anoffsetargument to the rpc and an intersection-observer sentinel that calls for the next page. - 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%'is the simplest form and the right choice for this recipe's focus. For production 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.