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.

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:
- A controlled input that updates state on every keystroke
- A
.filter()applied to the data array, derived from the search state - 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:
- Controlled inputs — React owns the value, not the DOM
- Derived data — compute from state, don't store redundant state
- Empty states — always design and implement the "no results" case
These patterns appear constantly in real product work. Understand them here and you'll recognise them everywhere.