← Back to blog

Building Search and Filter in React — A Designer's Guide

Search and filter are some of the most important UX patterns in product design. Here's how to build them in React using controlled inputs and derived data.

Amarneethi·
Building Search and Filter in React — A Designer's Guide

Why Search and Filter Matter

Search and filter are the difference between a list that scales and one that doesn't. Five items? You can browse. Fifty items? You need search. Five hundred? You need both search and filter.

These patterns appear in nearly every product — contact lists, product catalogues, user tables, notification feeds. Understanding how to build them in React means you can prototype them properly, not just draw them.

The Core Pattern

Search in React works through a combination of:

  1. A controlled input that updates state on every keystroke
  2. A .filter() applied to the data array, derived from the search state
  3. The filtered results rendered with .map()

Here's the minimal implementation:

'use client';

import { useState } from 'react';

const contacts = [
  { id: 1, name: 'Alice Johnson' },
  { id: 2, name: 'Bob Chen' },
  { id: 3, name: 'Carla Reyes' },
  { id: 4, name: 'David Park' },
];

export default function ContactSearch() {
  const [query, setQuery] = useState('');

  const filtered = contacts.filter((contact) =>
    contact.name.toLowerCase().includes(query.toLowerCase())
  );

  return (
    <div className="p-6 max-w-sm space-y-4">
      <input
        type="text"
        placeholder="Search contacts..."
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        className="w-full px-4 py-2 rounded-xl border border-gray-200"
      />

      {filtered.length === 0 ? (
        <p className="text-gray-500 text-sm">No contacts match "{query}"</p>
      ) : (
        <ul className="space-y-2">
          {filtered.map((contact) => (
            <li key={contact.id} className="p-3 rounded-xl bg-gray-50 text-gray-900">
              {contact.name}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Breaking It Down

The Controlled Input

value={query}
onChange={(e) => setQuery(e.target.value)}

Every time the user types, setQuery updates the state with the new input value. The input displays query. This is a controlled component — React owns the value.

Derived Data

const filtered = contacts.filter((contact) =>
  contact.name.toLowerCase().includes(query.toLowerCase())
);

filtered is not stored in state. It's computed from contacts and query on every render. This is derived data — it's always in sync with both the full list and the current search query.

.toLowerCase() on both sides makes the search case-insensitive. "alice" will match "Alice Johnson".

The Empty State

{filtered.length === 0 ? (
  <p>No contacts match "{query}"</p>
) : (
  <ul>...</ul>
)}

Always handle the empty state. When search returns no results, users need to know the search worked — it just found nothing. A blank screen is worse than a helpful message.

Adding Multiple Filters

Real filter UIs often combine search text with category filters. The pattern extends naturally:

const [query, setQuery] = useState('');
const [role, setRole] = useState('all');

const filtered = contacts
  .filter((c) => role === 'all' || c.role === role)
  .filter((c) => c.name.toLowerCase().includes(query.toLowerCase()));

Chain .filter() calls for each filter dimension. Each filter is independent and they compose naturally.

What This Teaches

The search filter pattern teaches three transferable concepts:

These patterns appear constantly in real product work. Understand them here and you'll recognise them everywhere.