Debounced search

Search-as-you-type with createDebounced + async + AbortController. The fetch tracks the debounced signal, so each keystroke restarts cleanly.

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.

search.tsmain.tsxapi.ts
import { computed, signal, onCleanup } from "elements-kit/signals";
import { async } from "elements-kit/utilities/async";
import { createDebounced } from "elements-kit/utilities/debounced";
import { fakeFetch } from "./api";

export const query = signal("");
export const debounced = createDebounced(query, 250);

export const search = async(() => {
  const q = debounced();
  if (!q) return Promise.resolve([] as string[]);
  const ctrl = new AbortController();
  onCleanup(() => ctrl.abort());
  return fakeFetch(q, ctrl.signal);
}).start();

export const results = computed<string[]>(() => search.value ?? []);

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

Read tracked signals before the first await

async tracks signals only up to the first await. Read debounced() synchronously at the top — reading it inside a .then() won't subscribe.

// ❌ debounced() read after await — no tracking
const search = async(async () => {
  await someSetup();
  return fetch(`/api/search?q=${debounced()}`);
});

// ✅ read before any await
const search = async(() => {
  const q = debounced();
  return fetch(`/api/search?q=${q}`);
});

Empty-query short-circuit

Without the if (!q) return [] guard, the first keystroke ("") fires a wasted fetch. Pick whatever sentinel value matches your API's "no results" contract.

See also

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