import { useEffect, useState } from "react";
export function useDebouncedValue<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState<T>(value);
useEffect(() => {
const handle = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(handle);
}, [value, delay]);
return debounced;
}
import { useEffect, useState } from "react";
export interface SearchResult {
id: string;
title: string;
}
type Status = "idle" | "loading" | "success" | "error";
export function useSearch(term: string) {
const [results, setResults] = useState<SearchResult[]>([]);
const [status, setStatus] = useState<Status>("idle");
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const query = term.trim();
if (!query) {
setResults([]);
setStatus("idle");
setError(null);
return;
}
const controller = new AbortController();
setStatus("loading");
setError(null);
fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
})
.then((res) => {
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return res.json() as Promise<SearchResult[]>;
})
.then((data) => {
setResults(data);
setStatus("success");
})
.catch((err: unknown) => {
if (err instanceof DOMException && err.name === "AbortError") return;
setError(err instanceof Error ? err.message : "Unknown error");
setStatus("error");
});
return () => controller.abort();
}, [term]);
return { results, status, error };
}
import { useState } from "react";
import { useDebouncedValue } from "./useDebouncedValue";
import { useSearch } from "./useSearch";
export function SearchBox() {
const [input, setInput] = useState("");
const term = useDebouncedValue(input, 300);
const { results, status, error } = useSearch(term);
return (
<div className="search-box">
<input
type="search"
value={input}
placeholder="Search…"
onChange={(e) => setInput(e.target.value)}
aria-label="Search"
/>
{status === "loading" && <p className="hint">Searching…</p>}
{status === "error" && <p className="error">{error}</p>}
{status === "success" && results.length === 0 && (
<p className="hint">No matches for “{term}”.</p>
)}
<ul>
{results.map((r) => (
<li key={r.id}>{r.title}</li>
))}
</ul>
</div>
);
}
This snippet shows how a live search box avoids two classic problems at once: hammering the backend on every keystroke, and rendering results from a request that has already been superseded. The solution combines a debounce hook with AbortController and a small amount of state discipline.
In useDebouncedValue hook, the raw input value is delayed by a configurable delay. Each change schedules a setTimeout and the effect's cleanup clears the previous timer, so only the last value within the quiet window survives. This collapses a burst of keystrokes into a single downstream update, which is what actually triggers a fetch. The hook is generic over T so it works for strings or any serializable filter object.
useSearch hook reacts to that debounced term. On every change it creates a fresh AbortController and passes its signal into fetch. The effect's cleanup calls controller.abort(), which cancels the in-flight request whenever the term changes again or the component unmounts. This is the key to eliminating race conditions: without it, a slow early request could resolve after a fast later one and overwrite the correct results. An AbortError is expected and swallowed, while genuine failures are surfaced through error. The status field distinguishes idle, loading, success, and error so the UI can render meaningfully rather than guessing from results.length.
Note the guard for an empty term, which short-circuits to idle instead of firing a pointless request. Because fetch rejects with a DOMException named AbortError on cancellation, the code checks err.name rather than swallowing everything, so real network problems still bubble up.
SearchBox component wires it together: a controlled input feeds useDebouncedValue, whose output feeds useSearch. The component stays dumb, simply mapping status to loading, empty, and result views. The trade-off is a deliberate latency of delay milliseconds before results appear, which is almost always worth it versus the cost and flicker of unthrottled requests. This pattern generalizes to autocomplete, typeahead filters, and any input-driven remote query where staleness and request volume both matter.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
<form data-controller="query-sync" data-action="change->query-sync#apply">
<select name="status" class="rounded border p-2">
<option value="">Any</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
Filter UI that syncs query params via Stimulus (no front-end router)
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
export type Settled<R> =
| { status: 'fulfilled'; value: R }
| { status: 'rejected'; reason: unknown };
export interface ConcurrencyOptions {
limit: number;
Simple concurrency limiter for batch operations
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
Debounced live search with Stimulus + Turbo Streams
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
timeout: 15000,
Axios API client with interceptors
Share this code
Here's the card — post it anywhere.