Debounced search
Build a search box that waits for a pause in typing, aborts superseded requests, and shows loading, empty and error states.
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.
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;
}
["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.
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.