React API Search Functionality
Table of Contents + −
In the last lesson, React Displaying API Data, you learned to show fetched data cleanly. Now we’ll add search functionality so the user types in a box and the list filters to match.
🤔 Why add search?
The API hands you a big list, but the user wants only one or two items from it. Here’s why a search box helps:
- Scrolling through hundreds of results to find one is slow and annoying.
- The user usually knows part of what they want, like a name or a word.
- A search box lets them type that and instantly see only the matches.
- It makes even a huge dataset feel small and easy.
🔀 Two ways to search
Before any code, know that there are two approaches, and picking the right one matters.
- Client-side filtering: fetch all the data once, then filter it in the browser as the user types. No new requests.
- Server-side search: send the search text to the API, and the server returns only the matching results.
So the rule of thumb is simple.
- If the full list is small enough to load once, filter on the client. It’s instant and simple.
- If the data is huge, like millions of products, ask the server. You can’t load all of that into the browser.
💡 Client-side filtering
Let’s start with the common case. We fetch the list once, keep a search term in state, and filter the list as the user types. No extra requests.
import { useState, useEffect } from "react";
function UserSearch() { const [users, setUsers] = useState([]); const [query, setQuery] = useState(""); // what the user typed
useEffect(() => { fetch("https://jsonplaceholder.typicode.com/users") .then((res) => res.json()) .then((data) => setUsers(data)); }, []); // fetch once, no refetch on typing
// filter the list we already have, based on the query const filtered = users.filter((user) => user.name.toLowerCase().includes(query.toLowerCase()) );
return ( <div> <input type="text" placeholder="Search users..." value={query} onChange={(e) => setQuery(e.target.value)} /> <ul> {filtered.map((user) => ( <li key={user.id}>{user.name}</li> ))} </ul> </div> );}Walk through how the filtering works.
- We fetch all users once with an empty
[], so typing never triggers a new request. queryholds the search text, updated by the input’sonChange.filteredis computed on every render: keep only users whose name includes the query.- We use
.toLowerCase()on both sides so the search ignores capital letters. - We map over
filtered, notusers, so the screen shows only the matches.
Output
(query is empty - shows all users)Leanne GrahamErvin HowellClementine Bauch...
(user types "ce")Clementine Bauch (matches "ce")Don't store the filtered list in state
Notice filtered is a plain variable, not state. It’s calculated from users and query on each render, so it’s always up to date. Putting it in state would just be extra work to keep in sync.
🌐 Server-side search
When the data is too big to load at once, you send the query to the API and refetch when it changes. The URL carries the search term.
import { useState, useEffect } from "react";
function ProductSearch() { const [products, setProducts] = useState([]); const [query, setQuery] = useState("");
useEffect(() => { // ask the server for matches; refetch whenever query changes fetch(`https://dummyjson.com/products/search?q=${query}`) .then((res) => res.json()) .then((data) => setProducts(data.products)); }, [query]); // query is in the dependency array
return ( <div> <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search products..." /> <ul> {products.map((p) => ( <li key={p.id}>{p.title}</li> ))} </ul> </div> );}See the key differences from client-side.
- The query goes into the URL, like
?q=${query}, so the server knows what to look for. queryis in the dependency array, so the effect refetches every time it changes.- The server does the filtering and returns only the matches, so you never load everything.
⏳ Debounce: don’t fetch on every keystroke
With server-side search there’s a problem. Typing “laptop” fires six requests, one per letter. The fix is debouncing: wait until the user pauses typing before sending the request.
useEffect(() => { // wait 500ms after the last keystroke before fetching const timer = setTimeout(() => { fetch(`https://dummyjson.com/products/search?q=${query}`) .then((res) => res.json()) .then((data) => setProducts(data.products)); }, 500);
// if the user types again before 500ms, cancel the old timer return () => clearTimeout(timer);}, [query]);Here’s the clever bit, using the cleanup you learned with effects.
- On each keystroke, the effect runs and sets a timer for 500ms.
- If the user types again before that, the cleanup runs
clearTimeoutand cancels the pending timer. - So only when the user stops typing for 500ms does the fetch actually fire.
- One request after the pause, instead of one per letter.
Always debounce server search
Without debouncing, every keystroke is a request. Type fast and you flood the server, and responses can arrive out of order. A small delay like 300 to 500ms means one clean request after the user pauses.
⚠️ Common Mistakes
A common mistake is making the search case-sensitive, so “alex” doesn’t match “Alex”.
// ❌ case-sensitive - "alex" won't match "Alex"users.filter((u) => u.name.includes(query));
// ✅ lowercase both sides so case doesn't matterusers.filter((u) => u.name.toLowerCase().includes(query.toLowerCase()));Keep these in mind.
- Don’t forget
.toLowerCase()on both sides, or the search misses obvious matches. - Don’t store the filtered list in state. Compute it from the data and the query on each render.
- Don’t do server-side search without debouncing, or you fire a request on every keystroke.
✅ Best Practices
A few habits for good search.
- Filter on the client when the full list fits in the browser; ask the server when it’s too big.
- Lowercase both the value and the query so search ignores capitalization.
- Keep the filtered result as a computed variable, not state.
- Always debounce server-side search, around 300 to 500ms.
- Show an empty state when nothing matches, so the user knows the search worked but found nothing.
Start simple
Most apps you build while learning have small lists, so client-side filtering is perfect and instant. Reach for server-side search and debouncing only when the dataset is genuinely too large to load at once.
🧩 What You’ve Learned
- ✅ Search lets the user type and narrow a long list down to what they want
- ✅ Client-side filtering loads all data once and filters in the browser, instant and simple
- ✅ Server-side search sends the query to the API and refetches the matches, for huge datasets
- ✅ Keep the search term in state and compute the filtered list, don’t store it in state
- ✅ Lowercase both sides so the search ignores capital letters
- ✅ Debounce server-side search so you fetch once after a pause, not on every keystroke
Check Your Knowledge
Test what you learned. Pick an answer for each question, then click Check.
- 1
When is client-side filtering the right choice?
Why: If the whole list fits in the browser, you fetch it once and filter instantly as the user types. No extra requests needed.
- 2
Where should the filtered list live in client-side search?
Why: The filtered list is derived from the data and the query, so compute it during render. Putting it in state just creates extra syncing work.
- 3
Why do you debounce a server-side search?
Why: Without debouncing, every keystroke fires a request, flooding the server and risking out-of-order responses. A short delay sends one request after the user stops typing.
- 4
How do you make a search ignore capital letters?
Why: Lowercasing both the item value and the query before comparing means 'alex' matches 'Alex'. Otherwise includes is case-sensitive and misses matches.
🚀 What’s Next?
That wraps up the React API Integration module. You can now fetch data, handle loading and errors, display it, and search it. Next we move to a big topic: letting your app have multiple pages and move between them, with React Router.