Skip to content

React Tutorial Data and APIs

Search and Filtering Data in React

Lesson 96 of 156 2 min read By Deepak Paswan Updated Oct 10, 2026

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.

App.jsx
JSX
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:

  1. Debounce the input so you do not send a request for every key press.
  2. Ignore stale responses (race conditions): the answer to “re” might arrive after the answer to “react”.
  3. Keep showing the previous results while loading, with a small “Searching…” hint.
App.jsx
JSX
// 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:

App.jsx
JSX
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 includes is 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.