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. resource tags 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 reads query.peek() (not get()) because we drive reloads explicitly via the debounced refresh(). Reading with peek avoids subscribing the resource's internals to the query.
  • Clean teardown. onCleanup clears 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 blocks javascript: and other dangerous schemes in href/src. See Security.

Next: Infinite scroll.