Search-as-you-type: implementation plan
Debounced input with request cancellation for the product search box in the React app
TL;DR
Today the search box fires one request per keystroke and shows whichever response lands last. This plan adds a 250 ms debounce and cancels the superseded request with AbortController, so results always match what is in the box.
- In scope: the header search box, the
useSearchhook, the fetch client, tests, a feature flag. - Out of scope: server-side ranking, search history, keyboard navigation of results.
- Done means: typing "react hooks" never shows results for "react ho", and the Network tab shows aborted requests rather than parallel ones.
What the user sees
Four states of the search box. The key rule: old results stay on screen while a new request is loading, so the list never flashes empty.
IDLE (fewer than 2 characters)
+-----------------------------+
| (search) Search products... |
+-----------------------------+
Type 2 or more charactersLOADING (old results stay visible)
+-----------------------------+
| (search) react ho (...)|
+-----------------------------+
React Hooks Handbook (dim)
React Hooks in Depth (dim)RESULTS
+-----------------------------+
| (search) react hooks (x)|
+-----------------------------+
React Hooks Handbook
React Hooks in Depth
Hooks Cheat Sheet 3 hitsEMPTY and ERROR
+-----------------------------+
| (search) zzzq (x)|
+-----------------------------+
No results for "zzzq"
Error: Search failed (503)
[ Retry ]How the data flows
Everything between the keystroke and the screen runs in the browser except one request. The hook useSearch is the only place that owns an AbortController.
What happens when two queries overlap
The case this feature exists for: the user keeps typing while request A is still in flight.
Decisions worth a look
Debounce delay
Requests fired while typing "react hooks" in a recorded burst-typing session.
Data
| delay | requests |
|---|---|
| 0 ms | 11 |
| 150 ms | 6 |
| 250 ms | 3 |
| 400 ms | 2 |
Other choices
| decision | chosen | rejected | why |
|---|---|---|---|
| Cancellation | AbortController per effect | ignore-stale flag only | Cancels the network request as well as the update |
| Debounce | hook on the value | debounced callback | Works with React 18 Strict Mode double effects |
| Cache | in-memory Map, 60 s | SWR or React Query | No new dependency for one box |
| Min length | 2 characters | 1 character | One letter matches most of the catalog |
Code to review
1. The debounce hook
Debounce the value, not the handler. The cleanup clears the timer on every change, which is the whole trick.
// src/hooks/useDebouncedValue.ts
import { useEffect, useState } from 'react';
export function useDebouncedValue<T>(value: T, delayMs = 250): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(id);
}, [value, delayMs]);
return debounced;
}
2. The search hook with cancellation
The cleanup function aborts the previous request. The AbortError guard stops a cancelled request from being reported as a failure.
// src/hooks/useSearch.ts
const MIN_CHARS = 2;
export function useSearch(query: string): SearchState {
const [state, setState] = useState<SearchState>({ status: 'idle', items: [] });
useEffect(() => {
const q = query.trim();
if (q.length < MIN_CHARS) {
setState({ status: 'idle', items: [] });
return undefined;
}
const controller = new AbortController();
setState((s) => ({ ...s, status: 'loading' })); // keep old items on screen
searchApi(q, controller.signal)
.then((items) =>
setState({ status: items.length ? 'ok' : 'empty', items }),
)
.catch((err) => {
if (err.name === 'AbortError') return; // superseded, not a failure
setState({ status: 'error', items: [], error: err.message });
});
return () => controller.abort();
}, [query]);
return state;
}
3. The fetch client
// src/api/search.ts
export async function searchApi(q: string, signal: AbortSignal): Promise<Item[]> {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, { signal });
if (!res.ok) throw new Error(`Search failed (${res.status})`);
return (await res.json()).items;
}
4. The component
// src/components/SearchBox.tsx
export function SearchBox() {
const [text, setText] = useState('');
const debounced = useDebouncedValue(text, 250);
const { status, items, error } = useSearch(debounced);
return (
<div role="search">
<input
value={text}
onChange={(e) => setText(e.target.value)}
aria-busy={status === 'loading'}
aria-controls="search-results"
/>
<ResultsList id="search-results" status={status} items={items} error={error} />
</div>
);
}
5. The test that proves cancellation
// src/hooks/useSearch.test.ts
it('aborts the previous request when the query changes', async () => {
const signals: AbortSignal[] = [];
vi.spyOn(api, 'searchApi').mockImplementation((_q, signal) => {
signals.push(signal);
return new Promise(() => {}); // never resolves
});
const { rerender } = renderHook(({ q }) => useSearch(q), {
initialProps: { q: 're' },
});
rerender({ q: 'rea' });
expect(signals).toHaveLength(2);
expect(signals[0].aborted).toBe(true);
expect(signals[1].aborted).toBe(false);
});
State machine
| state | entered when | shows | leaves when |
|---|---|---|---|
| idle | fewer than 2 characters | hint text | 2 or more characters settle |
| loading | a request starts | previous items dimmed | response, error or abort |
| ok | 1 or more items return | result list | query changes |
| empty | 0 items return | No results message | query changes |
| error | non-2xx or network failure | message and Retry | query changes or Retry |
Files
| file | change | status |
|---|---|---|
src/hooks/useDebouncedValue.ts | new | Ready |
src/hooks/useSearch.ts | new | Ready |
src/api/search.ts | accept an AbortSignal | Ready |
src/components/SearchBox.tsx | wire both hooks | In progress |
src/components/ResultsList.tsx | add loading and error states | In progress |
src/hooks/useSearch.test.ts | new | Ready |
src/flags.ts | add searchDebounce flag | Ready |
Risks and edge cases
Test plan
| case | how | expected |
|---|---|---|
| Overlapping queries | unit test, fake timers | earlier signal aborted, later one live |
| Late A after B | mock A to resolve after B | results show B |
| Slow network | throttle to 3G in devtools | old items stay visible while loading |
| Empty and error | mock 200 with [] and 503 | empty message, error with Retry |
| Rapid backspace | hold Backspace to empty | returns to idle, no request |
| Strict Mode | mount in dev | at most one live request per query |
Schedule
Data
| task | start | end |
|---|---|---|
| Hooks and unit tests | 2026-10-20 | 2026-10-22 |
| API signal support | 2026-10-21 | 2026-10-23 |
| Component and states | 2026-10-22 | 2026-10-26 |
| Accessibility and IME pass | 2026-10-26 | 2026-10-28 |
| Flagged rollout | 2026-10-28 | 2026-10-30 |
Rollout
- 1Merge behind the
searchDebounceflag2026-10-26Priya - 2Enable for internal staff2026-10-28Priya
- 3Enable for 10% of traffic and watch request volume2026-10-29Marcus
- 4Full rollout, remove the flag next sprint2026-10-30Marcus
Open questions
- Does
/api/searchcancel work when the client disconnects? - Should the 60 s cache survive route changes, or reset on navigation?
- Is 2 characters the right minimum for non-Latin scripts?