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.

ASOasis
7 min read
Build a Fast, Accessible React Filter + Sort List Component (with Hooks)

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