Debounce a search field properly
Avoid N requests per keystroke. Cancel in-flight requests with AbortController so the latest query always wins.
A search-as-you-type field is one of the most expensive UI patterns in cost-per-keystroke. Naive code fires a request on every key press. The user types "rama" and your server gets four requests in 100 ms. The responses arrive out of order, and the UI flashes through "results for r", "results for ra", "results for rama" before settling on... whichever response happened to land last. This recipe fixes it with debouncing, abort, and minimum-length filtering.
The naive version
One useEffect, fires on every change ofquery. Good intuition, broken in practice.
function Search() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
useEffect(() => {
if (!query) return setResults([]);
fetch(`/public/v2/users?name=${encodeURIComponent(query)}`)
.then(r => r.json())
.then(setResults);
}, [query]);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
Three problems:
- Every keystroke is a request.
- Responses can arrive out of order, so "ram" beats "rama" because "rama" was a slower query.
- No loading indicator, no minimum length, no cleanup.
The fixed version
Three improvements:
- Debounce. Wait until the user stops typing for 250 ms before firing.
- AbortController. Cancel earlier requests when a new one starts. Whichever request was fired last wins.
- Minimum length. Do not search for one character; returns are too broad to be useful.
import { useEffect, useState } from "react";
function useDebouncedValue(value, ms = 250) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const t = setTimeout(() => setDebounced(value), ms);
return () => clearTimeout(t);
}, [value, ms]);
return debounced;
}
function Search() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const debounced = useDebouncedValue(query, 250);
useEffect(() => {
if (debounced.length < 2) return setResults([]);
const ac = new AbortController();
fetch(`/public/v2/users?name=${encodeURIComponent(debounced)}`,
{ signal: ac.signal })
.then(r => r.json())
.then(setResults)
.catch(err => { if (err.name !== "AbortError") throw err; });
return () => ac.abort();
}, [debounced]);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
The debounce hook
Pulled out as a hook so you can reuse it for any debounced state, not just search:
const debounced = useDebouncedValue(query, 250);The hook delays thevalue by N ms. Each new value resets the timer. The cleanupclearTimeout is what makes it work; it cancels the previous timer when the user types again.
Why AbortController matters
Even with debouncing, two requests can be in flight at once if the user types, pauses 300 ms, then types more. The first request fires, then the second cancels-and-fires. Without abort, both responses arrive; whichever onesetResults is called with last wins, which is not necessarily the most recent query.
Abort fixes it. The cleanup function inuseEffect callsac.abort(), which causesfetch to reject withAbortError. We filter that out in the catch so it does not become a visible error.
Minimum length
Searching for "a" returns half the database. It is rarely useful. Skip the request below 2 characters, ideally 3:
if (debounced.length < 2) return setResults([]);
Tune the cutoff to your domain. For names, 2 is usually fine; for emails, 3 is better; for free-text descriptions, 4 or 5.
Loading state
Add a busy flag so the user knows a search is in flight:
const [busy, setBusy] = useState(false);
// ...inside the effect:
setBusy(true);
fetch(...).finally(() => setBusy(false));
// ...in the JSX:
{busy && <Spinner />}
One detail: the busy flag should setoutside the abort cleanup so an aborted request does not leave the spinner spinning. The.finally on the fetch handles that.
Server-side empty-state for short queries
If the user searches for "a" and gets no results, do not show "no users found"; it is misleading. Show a hint instead: "Type at least 2 characters". Same idea for empty input: show recent searches, suggested filters, or just nothing. Empty-state-as-zero-results is one of the worst UI choices.
What about server-side debouncing?
Some teams handle the burst by debouncing on the server (queue the request, drop earlier ones). It works, but it does not save the network round-trips; the client still sent N requests. Client-side debouncing is strictly better.
With Go REST
Filter on thename query parameter; substring match. The full URL pattern is:
/public/v2/users?name=avani&status=activeCombine with status, gender, or email for richer filters. Substring matching on multiple fields means you can build a "type-anywhere" search by passing the same value to several?name=&email= parameters.
Common mistakes
- Debouncing the API call instead of the value. Wrap the value, not the side effect; that is cleaner.
- Forgetting to cancel. Without abort, responses can arrive out of order.
- Showing "no results" for short queries. Misleading. Show a hint.
- Hardcoding 250 ms everywhere. Tune per use case. Search filters: 250-300. Auto-save fields: 1000-2000.
Keep building
Test loading skeletons with simulated latency
Use ?delay=N to make Go REST take seconds to respond, so you can verify your skeleton screens render without flicker.
Test 401, 422, 429 and 500 handlers
Force any status code with ?force_status= to make sure your error UI never shows blank screens or eats stack traces.
Build a paginated user list in React
A working component that fetches a page, renders rows, and uses X-Pagination-Pages to render Previous/Next correctly.