Runnable lesson · press Try it on the examples to edit and run them here.
Search boxes and filters are on almost every data screen. There are two ways to build them, and the right one depends on the size of the data.
Option 1: filter on the client
If the whole list is already in the browser (a few hundred or a few thousand items), filter it while rendering. It is instant and needs no extra requests.
const COUNTRIES = ["Brazil", "Canada", "France", "India", "Japan", "Kenya"];
function App() {
const [query, setQuery] = React.useState("");
const q = query.trim().toLowerCase();
const visible = COUNTRIES.filter((c) => c.toLowerCase().includes(q));
return (
<div>
<label>
Search <input aria-label="Search" value={query} onChange={(e) => setQuery(e.target.value)} />
</label>
<p>Showing {visible.length} of {COUNTRIES.length}</p>
{visible.length === 0 ? (
<p>No matches</p>
) : (
<ul>{visible.map((c) => <li key={c}>{c}</li>)}</ul>
)}
</div>
);
}
Only the query is state. visible is derived (Filtering and Sorting Lists). This is the best approach whenever you can use it.
Option 2: search on the server
When the data is large or private (thousands of products, a database), send the query to the server and show the results it returns. Three details make it work well:
- Debounce the input so you do not send a request for every key press.
- Ignore stale responses (race conditions): the answer to “re” might arrive after the answer to “react”.
- Keep showing the previous results while loading, with a small “Searching…” hint.
// Pretend server: slower for short queries to make races possible
function searchApi(query) {
const all = ["React", "Redux", "Remix", "Router", "Rust", "Vite", "Vue"];
const delay = query.length < 3 ? 500 : 200;
return new Promise((resolve) =>
setTimeout(() => resolve(all.filter((x) => x.toLowerCase().includes(query.toLowerCase()))), delay)
);
}
function App() {
const [query, setQuery] = React.useState("");
const [results, setResults] = React.useState([]);
const [searching, setSearching] = React.useState(false);
const [searched, setSearched] = React.useState("");
React.useEffect(() => {
if (query.trim() === "") { setResults([]); setSearched(""); return; }
let ignore = false; // only the latest request may set state
const timer = setTimeout(() => { // debounce: wait 300 ms after the last key
setSearching(true);
searchApi(query.trim()).then((found) => {
if (ignore) return;
setResults(found);
setSearched(query.trim());
setSearching(false);
});
}, 300);
return () => { ignore = true; clearTimeout(timer); };
}, [query]);
return (
<div>
<input aria-label="Search" placeholder="Type to search" value={query} onChange={(e) => setQuery(e.target.value)} />
<p role="status">{searching ? "Searching..." : searched ? `Results for ${searched}` : "Type to search"}</p>
<ul>{results.map((r) => <li key={r}>{r}</li>)}</ul>
</div>
);
}
How it works: every change of query runs the effect. Its cleanup runs first, which cancels the pending timer (debounce) and marks the earlier request as ignored (race condition). Only the last query’s result is allowed to update the screen.
Filters: combine controls
Several filters work together as a chain, each one narrowing the list:
const PRODUCTS = [
{ id: 1, name: "Novel", category: "books", stock: 3 },
{ id: 2, name: "Atlas", category: "books", stock: 0 },
{ id: 3, name: "Lamp", category: "home", stock: 5 },
{ id: 4, name: "Mug", category: "home", stock: 0 },
];
function App() {
const [category, setCategory] = React.useState("all");
const [inStock, setInStock] = React.useState(false);
const visible = PRODUCTS
.filter((p) => category === "all" || p.category === category)
.filter((p) => !inStock || p.stock > 0);
return (
<div>
<select aria-label="Category" value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="all">All</option>
<option value="books">Books</option>
<option value="home">Home</option>
</select>
<label><input type="checkbox" checked={inStock} onChange={(e) => setInStock(e.target.checked)} /> In stock only</label>
<p>Showing {visible.length}</p>
<ul>{visible.map((p) => <li key={p.id}>{p.name}</li>)}</ul>
</div>
);
}
Put filters in the URL
Search terms and filters should survive a reload and be shareable. Store them in the URL search parameters (?q=react&category=books) instead of component state. With React Router the useSearchParams hook does this; see Route Parameters. Users can then bookmark or share a filtered view, and the Back button behaves as they expect.
Tips
- Normalise text: trim, lowercase, and consider removing accents for comparison.
- Highlight matches for clarity, but keep the text accessible.
- Announce result counts in a
role="status"region. - Do not search on every character for tiny queries when the server is the one doing the work. A minimum of 2 or 3 characters is common.
- Escape user text when you turn it into a regular expression. Plain
includesis safer.
Key points
- Filter on the client when the data is small; derive the visible list from the query.
- For server search, debounce and ignore stale responses in the effect cleanup.
- Keep filters in the URL when they should be shareable, and announce result counts.