function GitHubSearch() {
const [username, setUsername] = useState("");
const [profile, setProfile] = useState(null);
const [error, setError] = useState("");
async function handleSearch(e) {
e.preventDefault();
setError("");
try {
const res = await fetch(`https://api.github.com/users/${username}`);
if (!res.ok) throw new Error("User not found");
setProfile(await res.json());
} catch (err) {
setError(err.message);
setProfile(null);
}
}
return (
<>
<form onSubmit={handleSearch}>
<input value={username} onChange={(e) => setUsername(e.target.value)} />
<button type="submit">Search</button>
</form>
{error && <p>{error}</p>}
{profile && <img src={profile.avatar_url} alt={profile.login} width={80} />}
</>
);
}Two different strategies depending on your dataset: for a small, already-fetched list, filter it entirely on the client with .filter() against the search text. For a large dataset, send the search term to the API itself (as in the GitHub example above) and let the server return only matching results โ client-side filtering doesn't scale to thousands of records.
const filtered = movies.filter((movie) =>
movie.title.toLowerCase().includes(searchText.toLowerCase())
);
const [page, setPage] = useState(1);
useEffect(() => {
fetch(`/api/movies?page=${page}`)
.then((res) => res.json())
.then(setMovies);
}, [page]);
<button onClick={() => setPage((p) => p - 1)} disabled={page === 1}>Previous</button>
<button onClick={() => setPage((p) => p + 1)}>Next</button>Notice that page is now in the effect's dependency array โ every time the user changes pages, the effect re-runs and fetches fresh data for that specific page.
Wiring a search input's onChange directly to an API call fires a new request on every single keystroke, overwhelming the API and often causing race conditions (Chapter 21). Real search features typically debounce the input โ waiting a few hundred milliseconds after the user stops typing before firing the request โ a pattern you'll build as a custom hook in Chapter 29.
Build a simple movie search box that filters a hardcoded local array of movie titles as the user types, without needing a real API.
const allMovies = ["Inception", "Interstellar", "The Matrix", "Inside Out"];
function MovieSearch() {
const [query, setQuery] = useState("");
const filtered = allMovies.filter(m =>
m.toLowerCase().includes(query.toLowerCase())
);
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} placeholder="Search movies..." />
<ul>
{filtered.map((movie, i) => <li key={i}>{movie}</li>)}
</ul>
</>
);
}
Q Should search filtering happen on the client or the server?
Client-side filtering is fine for a small, already-loaded dataset (hundreds of items or fewer). For large or growing datasets, filter on the server and only fetch matching results, to avoid downloading and processing unnecessary data.
Q What is debouncing and why does search need it?
Debouncing delays running a function (like an API call) until a pause in activity โ for search, until the user stops typing for a moment. Without it, every keystroke triggers a separate, often wasted, API request.