How to Build a Live Search Filter in React

Intermediate10 min interview
Skills tested:
Using a controlled input and filter() to narrow a list on every keystrokeComputing the filtered list as derived state (no extra useState, no useEffect)Using useMemo to avoid re-filtering an expensive list on unrelated re-rendersAdding debouncing to delay API calls when the search triggers a server fetchHandling the empty results case and showing a "no results" message

Advertisement

🧩 Scenario

Live search filters appear in user management panels, product catalogs, file explorers, and any data-heavy UI where the user needs to narrow a large list quickly. The client-side case is a single line of filter(). The API case requires debouncing. Understanding the difference between these two cases and knowing when to memoize is what distinguishes a candidate who has built production features from one who has only done tutorials.

Architecture Walkthrough

Derived State, Not Stored State

The most important principle: the filtered list is computed during render from the current query. It is not stored in a separate useState. Storing filtered results in state and updating them with useEffect is the derived state anti-pattern — it adds a state variable, a side effect, and a render cycle without any benefit. Just compute it: const filtered = items.filter(...).

When the query is empty, return all items. When no items match, return an empty array and show a "no results" message. Both cases are handled naturally by the same filter expression.

useMemo for Expensive Filtering

For lists with thousands of items or complex filter logic, the filter runs on every render of the parent component, even renders unrelated to the query. Wrapping with useMemo memoizes the result and only re-runs the filter when query or items changes.

For simple lists (hundreds of items, simple string match), the computation is fast enough that useMemo is not worth the overhead. Add it only when profiling shows the filter is a measurable bottleneck.

Debouncing for API Search

Client-side filtering does not need debouncing because it is synchronous and fast. API search does need debouncing to avoid sending a request on every keystroke. The pattern is: the input query updates immediately on every keystroke (for responsive UI), but the API call is delayed with setTimeout and cancelled if another keystroke arrives before the delay expires.


Key Code Explained

// Client-side filter: derived state computed during render
interface Item {
  id: string;
  name: string;
  category: string;
}

function SearchableList({ items }: { items: Item[] }) {
  const [query, setQuery] = useState('');

  // Derived during render — no useState, no useEffect
  const filtered = query
    ? items.filter(
        (item) =>
          item.name.toLowerCase().includes(query.toLowerCase()) ||
          item.category.toLowerCase().includes(query.toLowerCase()),
      )
    : items; // empty query shows all items

  return (
    <div>
      <input
        type="search"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search by name or category..."
      />

      <p>{filtered.length} result{filtered.length !== 1 ? 's' : ''}</p>

      {filtered.length === 0 ? (
        <p className="empty">No items match "{query}"</p>
      ) : (
        <ul>
          {filtered.map((item) => (
            <li key={item.id}>
              <strong>{item.name}</strong> — {item.category}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}


// With useMemo: memoize when the list is large or filter is complex
function LargeSearchableList({ items }: { items: Item[] }) {
  const [query, setQuery] = useState('');

  // Only re-filters when query or items changes
  const filtered = useMemo(
    () =>
      query
        ? items.filter((item) =>
            item.name.toLowerCase().includes(query.toLowerCase()),
          )
        : items,
    [query, items],
  );

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search..."
      />
      <ul>
        {filtered.map((item) => <li key={item.id}>{item.name}</li>)}
      </ul>
    </div>
  );
}


// API-driven search with debouncing
function ApiSearch() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<Item[]>([]);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    if (!query.trim()) {
      setResults([]);
      return;
    }

    // Debounce: delay the API call by 400ms
    const timer = setTimeout(async () => {
      setIsLoading(true);
      try {
        const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
        const data = await res.json();
        setResults(data.items);
      } finally {
        setIsLoading(false);
      }
    }, 400);

    // Cleanup: cancel the pending call if query changes before 400ms
    return () => clearTimeout(timer);
  }, [query]);

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}  // updates immediately
        placeholder="Search users..."
      />

      {isLoading && <p>Searching...</p>}

      <ul>
        {results.map((item) => <li key={item.id}>{item.name}</li>)}
      </ul>
    </div>
  );
}

In ApiSearch, the clearTimeout cleanup is what makes debouncing work. Every time the user types, the previous setTimeout is cancelled before the 400ms fires. Only when the user pauses for 400ms does the timer complete and the API call runs. Without the cleanup, every keystroke would queue an API call and all of them would execute with a 400ms delay each.


Tradeoffs

Search typeState neededDebounceuseMemoWhen to use
Client-side smallquery onlyNoNoLists under ~500 items
Client-side largequery onlyNoYesLists with 1000+ items
API searchquery + results + loadingYesNoServer-backed search

What Interviewers Actually Check

  • Whether you compute filtered results as derived state, not stored state
  • Whether you normalize case before comparison
  • Whether you handle empty query (show all) and no results (show message)
  • Whether you know debouncing is needed for API search but not client-side
  • Whether you know useMemo for expensive filters and can explain when to add it

Follow-Up Questions

  1. How would you highlight the matching substring in each search result to show the user why each item matched?
  2. How would you implement a debounce custom hook useDebounce(value, delay) that works for any value?
  3. How would React Query or SWR simplify the API search case compared to the manual useEffect + debounce approach?
  4. How would you extend the filter to support multiple fields simultaneously (name AND category AND tag)?
  5. How would you virtualize the filtered result list when it still has thousands of items after filtering?

Common Candidate Mistakes

  • Storing filtered results in useState and updating with useEffect, creating derived state that adds an unnecessary render cycle
  • Forgetting to normalize case: item.name.includes(query) does not match "React" when query is "react"
  • Debouncing the client-side filter when it is unnecessary (synchronous array operations are fast)
  • Not cleaning up the setTimeout in useEffect, causing API calls to run even after the component unmounts or the query changes
  • Not handling the empty query case, showing an empty list when the search field is blank instead of showing all items

Interview Readiness Checklist

Before you leave this question, make sure you can answer:

  • Can you build a live filter that updates with every keystroke using a controlled input and filter()?
  • Can you compute the filtered list as derived state during render without a useEffect?
  • Can you use useMemo to memoize an expensive filter and explain when it is worth adding?
  • Can you add debouncing to an API-driven search so the fetch only fires after the user pauses?
  • Can you handle the empty query case (show all) and the no-results case (show a message)?

Summary

A client-side search filter is a controlled input and a derived result list. The query lives in state. The filtered list is computed during render with filter(), case-normalized, and is not stored in a second useState. Empty query returns all items; no results shows a fallback message. This is straightforward because filter() is synchronous and fast for most list sizes.

For large lists (thousands of items), wrap the filter in useMemo with [query, items] as dependencies so the filter only re-runs when those values change, not on every parent re-render. For API-driven search, the query still updates immediately on every keystroke, but the API call is delayed with setTimeout inside useEffect and cancelled in the cleanup function. This is debouncing: only the final keystroke after a pause triggers the server request.

The most common mistake is the derived state anti-pattern: storing filtered results in useState and syncing them with useEffect. This adds an extra render cycle and a state variable that is always stale by exactly one render. Compute during render instead.

Related Videos
Watch these videos to learn more about this topic
Frequently Asked Questions

Should I debounce a client-side search filter?

For filtering an in-memory array, debouncing is unnecessary — the filter runs synchronously and fast. Debouncing is important when each keystroke triggers an API call, to avoid flooding the server.

Advertisement


Stay Updated

Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.

Advertisement