Skip to content

Debounced search

Search-as-you-type without the usual race conditions. createDebounced smooths the input, async re-runs whenever the debounced value changes, and onCleanup aborts the previous fetch before the next one starts.

The recipe

import { signal, onCleanup } from "elements-kit/signals";
import { async } from "elements-kit/utilities/async";
import { createDebounced } from "elements-kit/utilities/debounced";
const query = signal("");
const debounced = createDebounced(query, 250);
const search = async(() => {
const q = debounced(); // tracked
if (!q) return []; // skip empty query
const ctrl = new AbortController();
onCleanup(() => ctrl.abort()); // abort on re-run
return fetch(`/api/search?q=${encodeURIComponent(q)}`, {
signal: ctrl.signal,
}).then((r) => r.json());
}).start();

Type apr and the body re-runs three times — but only the last fetch survives. The first two are aborted by onCleanup before they resolve.

How each piece contributes

PrimitiveRole
signal (query)Bound to the input’s on:input event.
createDebounced(query, 250)Computed that mirrors query but only emits after 250 ms of silence.
async(() => …).start()Re-runs the body whenever debounced() changes. Read it before any await to be tracked.
onCleanup(() => ctrl.abort())Runs when the body re-fires — cancels in-flight requests.
AbortController + fetch({ signal })The cancellation primitive itself. fetch rejects with AbortError when aborted.

Variations

Retry on transient failure — wrap the fetch in retry, same as Data fetching:

import { retry } from "elements-kit/utilities/retry";
const search = async(() => {
const q = debounced();
if (!q) return [];
return retry(() => {
const ctrl = new AbortController();
onCleanup(() => ctrl.abort());
return fetch(`/api/search?q=${q}`, { signal: ctrl.signal })
.then((r) => r.json());
}, 2, (n) => n * 250)();
}).start();

Throttle instead of debounce — when intermediate results should appear while the user is still typing:

import { createThrottled } from "elements-kit/utilities/throttled";
const throttled = createThrottled(query, 250);

Refresh on tab return — read windowFocused() inside the body to refetch when the user comes back:

import { windowFocused } from "elements-kit/utilities/window-focus";
const search = async(() => {
windowFocused(); // re-runs on focus
const q = debounced();
// …
}).start();

Gotchas

See also

  • Data fetching — same async + onCleanup + abort pattern, plus retry, online, focus.
  • UtilitiescreateDebounced, createThrottled, retry.
  • Async — full async / Async reference.