[]previous and next page

Debounced search

Build a search box that waits for a pause in typing, aborts superseded requests, and shows loading, empty and error states.

For agentsUpdated

This recipe builds a search-as-you-type component. It demonstrates a debounced signal made from useEffect, setTimeout and onCleanup, and useResource(source, fetcher) fed by the debounced value, with the request canceled through info.signal when a newer term arrives.

The backend#

The client sends the current term to /api/fruits?q=... and passes the resource's abort signal to fetch. The endpoint returns a JSON array of fruit names. A failed HTTP response becomes the resource's error state.

src/search-api.tsts
export async function searchFruits(query: string, signal: AbortSignal): Promise<string[]> {
  const response = await fetch(`/api/fruits?q=${encodeURIComponent(query)}`, { signal });
  if (!response.ok) throw new Error(`Search failed (${response.status})`);
  const value: unknown = await response.json();
  if (!Array.isArray(value) || !value.every((item): item is string => typeof item === "string")) {
    throw new TypeError("Expected an array of fruit names");
  }
  return value;
}
Response for q=apjson
["Apple", "Apricot", "Grape", "Grapefruit", "Papaya"]

The component#

useDebounced returns a signal that follows value after delay milliseconds without a change. Search types into query, reads the settled debounced value, and gives useResource a source that is null for an empty term. A null, false or undefined source keeps the resource idle and aborts any request in flight.

src/search.tsxtsx
import { onCleanup, untrack, useEffect, useResource, useSignal, useStore } from "@shadow-js/core";
import { For } from "@shadow-js/web";
import { searchFruits } from "./search-api";

export const DEBOUNCE_MS = 200;

export function useDebounced<T>(value: () => T, delay: number): () => T {
  // useSignal: T is generic, and a plain-object value must stay one cell (useStore would make a store).
  const [debounced, setDebounced] = useSignal(untrack(value));
  useEffect(
    value,
    (next) => {
      const timer = setTimeout(() => setDebounced(() => next), delay);
      onCleanup(() => clearTimeout(timer));
    },
    { defer: true },
  );
  return debounced;
}

export function Search() {
  const [query, setQuery] = useStore("");
  const debounced = useDebounced(query, DEBOUNCE_MS);
  const term = (): string => debounced().trim();

  const results = useResource(
    () => (term() === "" ? null : term()),
    (text, info) => searchFruits(text, info.signal),
  );

  const shown = (): readonly string[] => (term() === "" ? [] : (results.latest ?? []));

  const status = (): string => {
    if (term() === "") return "Type to search";
    if (results.state === "errored") return "Search failed";
    if (results.loading) return "Searching";
    const count = shown().length;
    if (count === 0) return `No results for "${term()}"`;
    return `${count} ${count === 1 ? "result" : "results"}`;
  };

  return (
    <div>
      <label>
        Search fruits
        <input type="search" value={query()} onInput={(event) => setQuery(event.currentTarget.value)} />
      </label>
      <p role="status">{status()}</p>
      <ul aria-label="Results">
        <For each={shown()}>{(fruit) => <li>{fruit}</li>}</For>
      </ul>
    </div>
  );
}

Decisions#

Why a debounced signal and not a timer in the handler. The input handler only writes query, so the box stays responsive and the text is always current. The delay is a separate derived signal, which other code can also read.

useEffect(value, fn, { defer: true }). The two-argument form tracks only value and runs fn untracked, so writing setDebounced inside never subscribes the effect to debounced. defer: true skips the first run: the initial value is already in the signal, and no timer is needed for it. untrack(value) reads the starting value once without subscribing anything.

onCleanup inside the effect. A cleanup registered while an effect runs executes before the next run and when the owner is disposed. Every keystroke therefore cancels the pending timer before the new one starts, so only the last term in a burst reaches the backend, and unmounting the component leaves no timer behind. The effect may also return the cleanup function instead; both forms are equivalent.

The source decides when to fetch. useResource(source, fetcher) refetches whenever source() changes. While the source is null the resource is idle: its state is unresolved until it has loaded once and ready (with the last value) afterward, and any request in flight is aborted. A new term while a request is pending aborts the old request through the AbortSignal in info.signal and ignores its late result, even if the backend does not honor the signal.

results.latest, results.state and results.loading. Calling results() throws the fetch error to the nearest error boundary, and while the first load is pending it holds the nearest <Suspense> boundary. latest never throws, and state and loading are plain reads, so this component renders every state itself. During a refetch the previous list stays visible (state is refreshing) while the status line says "Searching".

The idle guard. After a term is cleared the resource keeps its last value, so the list is gated on term() instead of trusting results.latest.

Related: useResource, useEffect, useStore, onCleanup, untrack, ResourceInfo, For, flush.