Build a Fast, Accessible React Filter + Sort List Component (with Hooks)
Build a fast, accessible React filter + sort list with hooks, TypeScript, debouncing, and virtualization. Includes stable sorting and reusable patterns.
Image used for representation purposes only.
Overview
Building a filter + sort list in React sounds simple—until you need great performance, accessibility, and maintainability. This guide walks through a robust, production‑ready approach using modern React (hooks, memoization, transitions), with TypeScript examples you can drop into any app.
You will learn how to:
- Model state for filtering and sorting without duplication
- Implement stable, locale‑aware sorting
- Optimize with memoization, debouncing, and virtualization
- Make the UI accessible and keyboard friendly
- Generalize the component for reuse across data types
UX and Requirements
Before coding, define what “good” looks like:
- Fast: typing in search should feel instant, even on large lists
- Predictable: stable sort order; empty states are clear; resets are obvious
- Accessible: labeled controls, keyboard navigation, and live result counts
- Extensible: easy to add new filters or sort keys without rewiring
Data Model and State
Keep server data immutable. Derive the filtered/sorted view in-memory. Avoid storing derived arrays in state; compute them via useMemo from source data + UI state.
Core state shape:
- query: string
- sortKey: one of the item fields (e.g., “name”, “price”)
- sortDir: “asc” | “desc”
- facets: domain-specific filters (e.g., tags, ranges)
Utilities: Normalize, Filter, Stable Sort
// utils/sortFilter.ts
export const normalize = (s: string) =>
s
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '') // strip diacritics
.toLowerCase()
.trim();
const collator = new Intl.Collator(undefined, {
numeric: true,
sensitivity: 'base',
});
export const stableSort = <T,>(arr: T[], cmp: (a: T, b: T) => number): T[] =>
arr
.map((v, i) => [v, i] as const)
.sort((a, b) => cmp(a[0], b[0]) || a[1] - b[1])
.map(([v]) => v);
export const cmpBy = <T, K extends keyof T>(
key: K,
dir: 'asc' | 'desc' = 'asc'
) => (a: T, b: T) => {
const av = a[key];
const bv = b[key];
// Handle undefined/null consistently
if (av == null && bv == null) return 0;
if (av == null) return 1;
if (bv == null) return -1;
let res: number;
if (typeof av === 'number' && typeof bv === 'number') res = av - bv;
else res = collator.compare(String(av), String(bv));
return dir === 'asc' ? res : -res;
};
Example Domain: Products
export type Product = {
id: string;
name: string;
price: number; // cents or number—be consistent
rating?: number; // optional
tags: string[];
};
export const SORT_OPTIONS = [
{ key: 'name', label: 'Name' },
{ key: 'price', label: 'Price' },
{ key: 'rating', label: 'Rating' },
] as const;
Filter + Sort Component (TypeScript, no deps)
import React, { useMemo, useState, useTransition } from 'react';
import { cmpBy, normalize, stableSort } from './utils/sortFilter';
import type { Product } from './types';
type Props = {
items: Product[];
allTags?: string[]; // optional facet values for UI
initialSort?: keyof Product;
};
export function FilterSortList({ items, allTags = [], initialSort = 'name' }: Props) {
const [query, setQuery] = useState('');
const [sortKey, setSortKey] = useState<keyof Product>(initialSort);
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());
const [isPending, startTransition] = useTransition();
// Toggle tags without re-creating Set unnecessarily
const toggleTag = (tag: string) => {
startTransition(() => {
setSelectedTags(prev => {
const next = new Set(prev);
next.has(tag) ? next.delete(tag) : next.add(tag);
return next;
});
});
};
const normalizedQuery = normalize(query);
const filteredSorted = useMemo(() => {
const hasQuery = normalizedQuery.length > 0;
const hasTags = selectedTags.size > 0;
const filtered = items.filter(p => {
if (hasTags && !p.tags.some(t => selectedTags.has(t))) return false;
if (!hasQuery) return true;
const hay = normalize(`${p.name} ${p.tags.join(' ')}`);
return hay.includes(normalizedQuery);
});
return stableSort(filtered, cmpBy<Product, keyof Product>(sortKey, sortDir));
}, [items, normalizedQuery, selectedTags, sortKey, sortDir]);
const clearAll = () => {
setQuery('');
setSelectedTags(new Set());
setSortKey('name');
setSortDir('asc');
};
return (
<section aria-busy={isPending} className="fs-container">
<header className="fs-controls" aria-label="Filters and sorting controls">
<label>
<span className="sr-only">Search products</span>
<input
type="search"
placeholder="Search by name or tag…"
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-controls="results-list"
/>
</label>
<label>
Sort by
<select
value={String(sortKey)}
onChange={(e) => setSortKey(e.target.value as keyof Product)}
>
<option value="name">Name</option>
<option value="price">Price</option>
<option value="rating">Rating</option>
</select>
</label>
<button
type="button"
aria-pressed={sortDir === 'desc'}
onClick={() => setSortDir(d => (d === 'asc' ? 'desc' : 'asc'))}
title={`Toggle sort direction (currently ${sortDir})`}
>
{sortDir === 'asc' ? '⬆️ Asc' : '⬇️ Desc'}
</button>
{allTags.length > 0 && (
<fieldset className="fs-tags">
<legend>Tags</legend>
{allTags.map(tag => (
<label key={tag}>
<input
type="checkbox"
checked={selectedTags.has(tag)}
onChange={() => toggleTag(tag)}
/>
{tag}
</label>
))}
</fieldset>
)}
<button type="button" onClick={clearAll} aria-label="Clear all filters">
Reset
</button>
<output aria-live="polite" aria-atomic="true" className="fs-count">
{filteredSorted.length} result{filteredSorted.length !== 1 ? 's' : ''}
</output>
</header>
<ul id="results-list" className="fs-list" role="list">
{filteredSorted.map(p => (
<li key={p.id} className="fs-item">
<div className="fs-name">{p.name}</div>
<div className="fs-meta">
<span>${(p.price / 100).toFixed(2)}</span>
{p.rating != null && <span aria-label={`Rating ${p.rating} of 5`}>⭐ {p.rating}</span>}
<span className="fs-tagsline">{p.tags.join(', ')}</span>
</div>
</li>
))}
</ul>
</section>
);
}
Minimal styling (optional):
.fs-controls { display: grid; gap: .75rem; grid-auto-flow: column; align-items: end; }
.fs-tags { display: flex; gap: .5rem; flex-wrap: wrap; }
.fs-list { margin: 1rem 0; padding: 0; list-style: none; }
.fs-item { display: grid; grid-template-columns: 1fr auto; gap: .5rem; padding: .75rem; border-bottom: 1px solid #eee; }
.fs-name { font-weight: 600; }
.fs-meta { display: flex; gap: .75rem; align-items: center; color: #555; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
Example Usage
const PRODUCTS: Product[] = [
{ id: '1', name: 'React Handbook', price: 2999, rating: 4.7, tags: ['books', 'react'] },
{ id: '2', name: 'TypeScript Cap', price: 1999, rating: 4.6, tags: ['apparel'] },
{ id: '3', name: 'Keyboard Model M', price: 12999, rating: 4.9, tags: ['hardware'] },
{ id: '4', name: 'Next.js Stickers', price: 899, tags: ['stickers', 'react'] },
];
export default function Page() {
const tags = Array.from(new Set(PRODUCTS.flatMap(p => p.tags))).sort();
return <FilterSortList items={PRODUCTS} allTags={tags} initialSort="name" />;
}
Performance: Debouncing and Transitions
- Debounce keystrokes if filtering is expensive or remote:
function useDebounced<T>(value: T, ms = 150) {
const [v, setV] = React.useState(value);
React.useEffect(() => { const t = setTimeout(() => setV(value), ms); return () => clearTimeout(t); }, [value, ms]);
return v;
}
Use the debounced value inside useMemo when the dataset is large. For CPU-heavy lists, keep startTransition around tag toggles or search updates to keep the input responsive.
Virtualization (react-window)
For 10k+ rows, render only what’s visible:
import { FixedSizeList as List, ListChildComponentProps } from 'react-window';
function Row({ index, style, data }: ListChildComponentProps<Product[]>) {
const p = data[index];
return (
<li style={style} className="fs-item">
<div className="fs-name">{p.name}</div>
<div className="fs-meta">
<span>${(p.price / 100).toFixed(2)}</span>
{p.rating != null && <span>⭐ {p.rating}</span>}
<span>{p.tags.join(', ')}</span>
</div>
</li>
);
}
// Replace the <ul> block:
<List
height={480}
width={800}
itemCount={filteredSorted.length}
itemSize={56}
itemData={filteredSorted}
>
{Row}
</List>
Accessibility Checklist
- Label every control with visible text or sr-only spans
- Use type=“search” for the input; associate it with results via aria-controls
- Announce result counts via
- Ensure focus outlines are visible
- Keyboard: Tab through inputs; Space toggles checkboxes; Enter toggles sort direction button
- Color contrast: 4.5:1 minimum for text
Making It Generic and Config-Driven
Abstract the component so it’s reusable across entities:
type SortOption<T> = {
key: keyof T;
label: string;
compare?: (a: T, b: T) => number; // custom comparator when needed
};
type GenericProps<T> = {
items: T[];
getSearchHaystack: (item: T) => string; // fields to search
sortOptions: SortOption<T>[];
};
This allows plugging different item types without rewriting filter logic. Provide optional compare to override cmpBy for complex fields (e.g., dates, nested props).
Edge Cases and Pitfalls
- Do not keep filtered arrays in state—derive them; otherwise you chase sync bugs
- Normalize text to handle diacritics (café vs cafe)
- Handle undefined fields in comparators; define a clear order
- Use stable sort so items with equal key preserve input order
- Memoize expensive computations with useMemo and stable dependencies
- Avoid recreating Sets/Maps in deps; update via functional setState
- When lists are server-paginated, push filter/sort to the server and keep UI state client-side
Testing Essentials (React Testing Library)
import { render, screen, within } from '@testing-library/react';
import user from '@testing-library/user-event';
it('filters by query and toggles sort', async () => {
render(<FilterSortList items={PRODUCTS} allTags={["react", "books", "hardware", "apparel", "stickers"]} />);
await user.type(screen.getByRole('searchbox'), 'react');
const results = screen.getAllByRole('listitem');
expect(results.length).toBeGreaterThan(0);
await user.click(screen.getByRole('button', { name: /toggle sort direction/i }));
const first = within(results[0]).getByText(/react/i);
expect(first).toBeInTheDocument();
});
Production Tips
- Use React DevTools Profiler to confirm memoization is effective
- For extremely large datasets, offload filter/sort to a Web Worker
- If your app is localized, pass the active locale to Intl.Collator
- Persist UI state to the URL (query params) for shareable filters
Conclusion
A great filter + sort list in React is mostly about state discipline and performance hygiene: keep data immutable, derive views with memoization, sort stably with locale awareness, and scale with virtualization. Wrap it in accessible controls and you have a reusable component that feels fast at any size.
Related Posts
Build a Rock‑Solid React Countdown Timer (Hooks, TypeScript, Zero Drift)
Build a robust React countdown timer with hooks and TypeScript: zero-drift updates, pause/resume/reset, accessibility, SSR tips, tests, and examples.
Building a Fast, Accessible React Multi‑Select Dropdown: Patterns, Code, and Pitfalls
Build a fast, accessible React multi-select dropdown: API design, ARIA patterns, code, virtualization, async loading, performance, and testing.
React Search Autocomplete: A Fast, Accessible Implementation with Hooks
Build a fast, accessible React search autocomplete with hooks, debouncing, ARIA, caching, and virtualization. Includes complete, production-ready code.