Debounce with search
by Pratik Rai
Read the full write-upBuild a search box that waits for the typing to stop before it searches. Typing "strawberry" should send one request, not ten.
searchFruits(query) is given: it resolves after 400ms and counts how many times it was called.
Requirements
The search runs about 350ms after the last keystroke, not on every keystroke.
A pending search is cancelled when the query changes again.
Clearing the input clears the results without searching.
"Searching…" shows while a request is in flight.
Say so when a search genuinely found nothing — but not before any search has run.
A slow earlier response must not overwrite the results of a later one.
Notes
The request counter on screen is there to prove the debounce works: type a long word and it should barely move.
Cancelling a real request would use
AbortController; here a flag in the effect cleanup is the equivalent.
Hints
Debounce with search (reference solution)
One way to do it, not the only way. Yours passes if it renders.
Solution files
1import { useState, useEffect, useRef } from 'react';
2
3const FRUITS = [
4 'Apple', 'Apricot', 'Avocado', 'Banana', 'Blackberry', 'Blueberry',
5 'Cherry', 'Clementine', 'Coconut', 'Cranberry', 'Date', 'Dragonfruit',
6 'Fig', 'Grape', 'Grapefruit', 'Guava', 'Kiwi', 'Lemon', 'Lime', 'Lychee',
7 'Mango', 'Melon', 'Nectarine', 'Orange', 'Papaya', 'Passionfruit', 'Peach',
8 'Pear', 'Persimmon', 'Pineapple', 'Plum', 'Pomegranate', 'Raspberry',
9 'Strawberry', 'Tangerine', 'Watermelon',
10];
11
12let requestCount = 0;
13
14// Stands in for a search endpoint: 400ms, and it counts how often it was
15// called so you can see the debounce working.
16function searchFruits(query) {
17 requestCount += 1;
18 return new Promise((resolve) => {
19 setTimeout(() => {
20 const q = query.trim().toLowerCase();
21 resolve(
22 q === '' ? [] : FRUITS.filter((f) => f.toLowerCase().includes(q))
23 );
24 }, 400);
25 });
26}
27
28const DEBOUNCE_MS = 350;
29
30export default function App() {
31 const [query, setQuery] = useState('');
32 const [results, setResults] = useState([]);
33 const [loading, setLoading] = useState(false);
34 const [searched, setSearched] = useState(false);
35 const [sent, setSent] = useState(0);
36
37 useEffect(() => {
38 const trimmed = query.trim();
39 if (trimmed === '') {
40 setResults([]);
41 setSearched(false);
42 setLoading(false);
43 return undefined;
44 }
45
46 setLoading(true);
47 let cancelled = false;
48 // Cancelled below if the query changes first, which is the debounce: the
49 // timer only survives to fire once the typing has paused.
50 const timer = setTimeout(async () => {
51 const found = await searchFruits(trimmed);
52 setSent(requestCount);
53 // Guard against an out-of-order response. Requests are 400ms and the
54 // debounce is 350ms, so a slow earlier search can still land after a
55 // faster later one and overwrite good results with stale ones.
56 if (!cancelled) {
57 setResults(found);
58 setSearched(true);
59 setLoading(false);
60 }
61 }, DEBOUNCE_MS);
62
63 return () => {
64 cancelled = true;
65 clearTimeout(timer);
66 };
67 }, [query]);
68
69 return (
70 <div className="search">
71 <h1>Debounced search</h1>
72
73 <input
74 type="search"
75 value={query}
76 placeholder="Search fruit"
77 onChange={(event) => setQuery(event.target.value)}
78 aria-label="Search fruit"
79 />
80
81 <p className="state" aria-live="polite">{loading ? 'Searching…' : ''}</p>
82
83 {results.length > 0 ? (
84 <ul className="results">
85 {results.map((name) => (
86 <li key={name}>{name}</li>
87 ))}
88 </ul>
89 ) : (
90 searched &&
91 !loading && <p className="empty">Nothing matches that.</p>
92 )}
93
94 <p className="log">Requests sent: {sent}</p>
95 </div>
96 );
97}How it works
People reach for a debounce utility here, and then have to explain how it interacts with React state. The effect cleanup already is a debounce: an effect keyed on the query runs after every change, and its cleanup runs before the next one, so scheduling in the body and clearing in the cleanup means only the final keystroke ever fires a timer. No utility, no ref holding a timer id.
The race is the part that separates a working search from one that looks right in a demo. The request takes 400ms and the debounce is 350ms, so pausing briefly mid-word can put two searches in the air at once — and there is no guarantee they land in order. The cancelled flag, flipped in the cleanup, means a response from a query the user has already moved on from is computed and thrown away instead of overwriting the current one.
searched exists so the empty state does not lie. Without it, "Nothing matches that" appears on first paint, before the visitor has typed anything at all.