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.

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

Terminal
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 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

Terminal
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 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.ehtml
import "./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.ts
import { 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.ehtml
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="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.ts
import 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-feed does: pass the last row's name and id to the RPC, add and (name, id) > (${lastName}, ${lastId}), order by name, id, and call for the next page from an intersection-observer sentinel.
  • 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%' matches substrings. For language-aware 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.