Debounced search
A typeahead that waits until the user stops typing, then fetches — and never lets a slow earlier request overwrite a newer one. Two pieces: a debounce on the input, and a resource for the fetch (which is already race-safe).
import { html, mount, createState, each, onCleanup } from "@zoijs/core";
import { resource } from "@zoijs/resource";
const search = (q) =>
fetch(`/api/search?q=${encodeURIComponent(q)}`).then((r) => r.json());
function Search() {
const query = createState("");
// The resource reads the CURRENT query when it runs; refresh() re-runs it.
// resource is race-safe: a slow response for an old query can't overwrite a
// newer one (see @zoijs/resource).
const results = resource(() => {
const q = query.peek().trim();
return q ? search(q) : [];
});
// Debounce: refresh 250ms after the last keystroke.
let timer;
const onInput = (e) => {
query.set(e.target.value);
clearTimeout(timer);
timer = setTimeout(() => results.refresh(), 250);
};
onCleanup(() => clearTimeout(timer)); // never fire after unmount
return html`
<section role="search">
<input
type="search"
value=${() => query.get()}
oninput=${onInput}
placeholder="Search…"
aria-label="Search"
/>
${() => results.loading() && html`<span class="spinner" aria-live="polite">Searching…</span>`}
${() => results.error() && html`<p class="error">Search failed.</p>`}
<ul>
${each(
() => results.data() ?? [],
(hit) => hit.id,
(hit) => html`<li><a href=${`/item/${hit.id}`}>${hit.title}</a></li>`
)}
</ul>
${() =>
!results.loading() && query.get().trim() && (results.data() ?? []).length === 0
? html`<p>No matches for “${query.get()}”.</p>`
: null}
</section>
`;
}
mount(Search, "#app");Why this is robust#
- Debounce, not throttle. The timer resets on every keystroke and only fires
refresh()once typing pauses for 250ms — so a 12-character query is one request, not twelve. - No stale results.
resourcetags each load and ignores any response that isn't the latest, so out-of-order network replies can't flash an old result set. You get that for free — no request-id bookkeeping in your component. peek()inside the fetcher. The fetcher readsquery.peek()(notget()) because we drive reloads explicitly via the debouncedrefresh(). Reading withpeekavoids subscribing the resource's internals to the query.- Clean teardown.
onCleanupclears the pending timer, so navigating away never triggers a fetch into a dead component. - Safe URLs.
href=${/item/${hit.id}}is a relative path; Zoijs also blocksjavascript:and other dangerous schemes inhref/src. See Security.
Next: Infinite scroll.