Back to catalog

search-filter-builder

Build search and filter UI with debounce, multi-select filters, faceted search. TanStack Table filters, URL state. Keywords - search, filter, debounce, faceted search, multi filter, tanstack table filter, search box, filter chips

  • Category: discovered
  • Tags: github, community
  • Version: 1.0.0
  • Author: walid
  • Downloads: 0
  • Updated: 2025-11-22

Download the latest version (v1.0.0) from this page.

SKILL.md

---
name: search-filter-builder
description: Build search and filter UI with debounce, multi-select filters, faceted search. TanStack Table filters, URL state. Keywords - search, filter, debounce, faceted search, multi filter, tanstack table filter, search box, filter chips
---

# Search/Filter Builder

## When to Use This Skill

Activate when:
- Building search boxes with debouncing
- Implementing multi-select filters
- Creating faceted search (filter by multiple criteria)
- Filter large datasets in tables or lists
- Need URL-based filters (shareable/bookmarkable)

## What This Skill Does

Creates search and filter UI with:
- Search input with debouncing
- Multi-select filters (checkboxes, dropdowns)
- Filter chips (active filters display)
- Clear all filters
- URL state synchronization
- Real-time filtering with performance optimization

## Supported Technologies

**React**:
- TanStack Table (built-in filtering)
- React Hook Form (filter forms)
- use-debounce

**Vue 3**:
- VueUse - useDebounceFn
- Pinia (filter state)

## Example: Search with Debounce (React)

```tsx
// components/SearchInput.tsx
import { useState, useEffect } from 'react';
import { useDebounce } from 'use-debounce';
import { MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/20/solid';

interface SearchInputProps {
  onSearch: (query: string) => void;
  placeholder?: string;
  debounceMs?: number;
}

export function SearchInput({
  onSearch,
  placeholder = 'Search...',
  debounceMs = 300,
}: SearchInputProps) {
  const [query, setQuery] = useState('');
  const [debouncedQuery] = useDebounce(query, debounceMs);

  useEffect(() => {
    onSearch(debouncedQuery);
  }, [debouncedQuery, onSearch]);

  return (
    <div className="relative">
      <div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
        <MagnifyingGlassIcon className="h-5 w-5 text-gray-400" />
      </div>
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder={placeholder}
        className="block w-full rounded-md border border-gray-300 py-2 pl-10 pr-10 focus:border-indigo-500 focus:ring-indigo-500"
      />
      {query && (
        <button
          onClick={() => setQuery('')}
          className="absolute inset-y-0 right-0 flex items-center pr-3"
        >
          <XMarkIcon className="h-5 w-5 text-gray-400 hover:text-gray-600" />
        </button>
      )}
    </div>
  );
}
```

## Example: Multi-Select Filters

```tsx
// components/FilterPanel.tsx
import { useState } from 'react';

interface FilterOption {
  value: string;
  label: string;
  count?: number;
}

interface FilterPanelProps {
  categories: FilterOption[];
  statuses: FilterOption[];
  onFilterChange: (filters: { categories: string[]; statuses: string[] }) => void;
}

export function FilterPanel({
  categories,
  statuses,
  onFilterChange,
}: FilterPanelProps) {
  const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
  const [selectedStatuses, setSelectedStatuses] = useState<string[]>([]);

  const handleCategoryChange = (value: string, checked: boolean) => {
    const newCategories = checked
      ? [...selectedCategories, value]
      : selectedCategories.filter((c) => c !== value);

    setSelectedCategories(newCategories);
    onFilterChange({ categories: newCategories, statuses: selectedStatuses });
  };

  const handleStatusChange = (value: string, checked: boolean) => {
    const newStatuses = checked
      ? [...selectedStatuses, value]
      : selectedStatuses.filter((s) => s !== value);

    setSelectedStatuses(newStatuses);
    onFilterChange({ categories: selectedCategories, statuses: newStatuses });
  };

  const clearAll = () => {
    setSelectedCategories([]);
    setSelectedStatuses([]);
    onFilterChange({ categories: [], statuses: [] });
  };

  return (
    <div className="space-y-6">
      {/* Filter header */}
      <div className="flex items-center justify-between">
        <h3 className="text-lg font-medium">Filters</h3>
        {(selectedCategories.length > 0 || selectedStatuses.length > 0) && (
          <button
            onClick={clearAll}
            className="text-sm text-indigo-600 hover:text-indigo-500"
          >
            Clear all
          </button>
        )}
      </div>

      {/* Category filters */}
      <div>
        <h4 className="text-sm font-medium text-gray-900 mb-3">Category</h4>
        <div className="space-y-2">
          {categories.map((category) => (
            <label key={category.value} className="flex items-center gap-2">
              <input
                type="checkbox"
                checked={selectedCategories.includes(category.value)}
                onChange={(e) =>
                  handleCategoryChange(category.value, e.target.checked)
                }
                className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
              />
              <span className="text-sm text-gray-700">{category.label}</span>
              {category.count !== undefined && (
                <span className="text-sm text-gray-400">({category.count})</span>
              )}
            </label>
          ))}
        </div>
      </div>

      {/* Status filters */}
      <div>
        <h4 className="text-sm font-medium text-gray-900 mb-3">Status</h4>
        <div className="space-y-2">
          {statuses.map((status) => (
            <label key={status.value} className="flex items-center gap-2">
              <input
                type="checkbox"
                checked={selectedStatuses.includes(status.value)}
                onChange={(e) =>
                  handleStatusChange(status.value, e.target.checked)
                }
                className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
              />
              <span className="text-sm text-gray-700">{status.label}</span>
              {status.count !== undefined && (
                <span className="text-sm text-gray-400">({status.count})</span>
              )}
            </label>
          ))}
        </div>
      </div>
    </div>
  );
}
```

## Example: Filter Chips (Active Filters)

```tsx
// components/FilterChips.tsx
import { XMarkIcon } from '@heroicons/react/20/solid';

interface Filter {
  key: string;
  label: string;
  value: string;
}

interface FilterChipsProps {
  filters: Filter[];
  onRemove: (key: string, value: string) => void;
  onClearAll: () => void;
}

export function FilterChips({ filters, onRemove, onClearAll }: FilterChipsProps) {
  if (filters.length === 0) return null;

  return (
    <div className="flex flex-wrap items-center gap-2">
      {filters.map((filter, index) => (
        <span
          key={`${filter.key}-${filter.value}-${index}`}
          className="inline-flex items-center gap-1 rounded-full bg-indigo-100 px-3 py-1 text-sm text-indigo-800"
        >
          <span className="font-medium">{filter.label}:</span>
          <span>{filter.value}</span>
          <button
            onClick={() => onRemove(filter.key, filter.value)}
            className="ml-1 hover:text-indigo-600"
          >
            <XMarkIcon className="h-4 w-4" />
          </button>
        </span>
      ))}
      {filters.length > 1 && (
        <button
          onClick={onClearAll}
          className="text-sm text-gray-600 hover:text-gray-900 underline"
        >
          Clear all
        </button>
      )}
    </div>
  );
}
```

## Example: URL State Synchronization

```tsx
// hooks/useFilterParams.ts
import { useSearchParams } from 'react-router-dom';
import { useCallback } from 'react';

export function useFilterParams() {
  const [searchParams, setSearchParams] = useSearchParams();

  const getFilter = useCallback(
    (key: string): string[] => {
      const value = searchParams.get(key);
      return value ? value.split(',') : [];
    },
    [searchParams]
  );

  const setFilter = useCallback(
    (key: string, values: string[]) => {
      const newParams = new URLSearchParams(searchParams);
      if (values.length > 0) {
        newParams.set(key, values.join(','));
      } else {
        newParams.delete(key);
      }
      setSearchParams(newParams);
    },
    [searchParams, setSearchParams]
  );

  const clearAllFilters = useCallback(() => {
    setSearchParams(new URLSearchParams());
  }, [setSearchParams]);

  return {
    getFilter,
    setFilter,
    clearAllFilters,
  };
}

// Usage
function ProductList() {
  const { getFilter, setFilter, clearAllFilters } = useFilterParams();

  const categories = getFilter('category');
  const statuses = getFilter('status');

  const handleFilterChange = (filters: { categories: string[]; statuses: string[] }) => {
    setFilter('category', filters.categories);
    setFilter('status', filters.statuses);
  };

  // URL: /products?category=electronics,books&status=active
}
```

## Example: TanStack Table with Filters

```tsx
// components/ProductTable.tsx
import { useReactTable, getCoreRowModel, getFilteredRowModel, ColumnDef } from '@tanstack/react-table';
import { useState } from 'react';

interface Product {
  id: number;
  name: string;
  category: string;
  status: string;
  price: number;
}

export function ProductTable({ data }: { data: Product[] }) {
  const [globalFilter, setGlobalFilter] = useState('');
  const [columnFilters, setColumnFilters] = useState([]);

  const columns: ColumnDef<Product>[] = [
    {
      accessorKey: 'name',
      header: 'Name',
      enableColumnFilter: true,
    },
    {
      accessorKey: 'category',
      header: 'Category',
      filterFn: 'arrIncludesSome', // For multi-select
    },
    {
      accessorKey: 'status',
      header: 'Status',
    },
    {
      accessorKey: 'price',
      header: 'Price',
      filterFn: 'inNumberRange',
    },
  ];

  const table = useReactTable({
    data,
    columns,
    state: {
      globalFilter,
      columnFilters,
    },
    onGlobalFilterChange: setGlobalFilter,
    onColumnFiltersChange: setColumnFilters,
    getCoreRowModel: getCoreRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
  });

  return (
    <div>
      {/* Global search */}
      <SearchInput onSearch={setGlobalFilter} />

      {/* Table */}
      <table>
        {/* ... table rendering ... */}
      </table>

      <div className="mt-4 text-sm text-gray-600">
        Showing {table.getRowModel().rows.length} of {data.length} results
      </div>
    </div>
  );
}
```

## Example: Vue 3 Search with Debounce

```vue
<!-- components/SearchInput.vue -->
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useDebounceFn } from '@vueuse/core';

const emit = defineEmits<{
  search: [query: string];
}>();

const query = ref('');

const debouncedSearch = useDebounceFn((value: string) => {
  emit('search', value);
}, 300);

watch(query, (newValue) => {
  debouncedSearch(newValue);
});
</script>

<template>
  <div class="relative">
    <input
      v-model="query"
      type="text"
      placeholder="Search..."
      class="block w-full rounded-md border border-gray-300 py-2 pl-10 pr-10 focus:border-indigo-500 focus:ring-indigo-500"
    />
    <button
      v-if="query"
      @click="query = ''"
      class="absolute inset-y-0 right-0 flex items-center pr-3"
    >
      ×
    </button>
  </div>
</template>
```

## Best Practices

1. **Debounce search** - Wait 300ms before triggering search
2. **URL state** - Sync filters to URL for shareable links
3. **Clear filters** - Provide "Clear all" button
4. **Filter chips** - Show active filters visually
5. **Count results** - Show "X of Y results"
6. **Keyboard shortcuts** - Cmd+K to focus search
7. **Loading states** - Show loading during filter

## Performance Tips

```tsx
// ❌ BAD: Filter on every keystroke
<input onChange={(e) => filterData(e.target.value)} />

// ✅ GOOD: Debounce search
const [debouncedQuery] = useDebounce(query, 300);
useEffect(() => filterData(debouncedQuery), [debouncedQuery]);
```

## Integration with Other Skills

- **table-datagrid-builder** - Add search/filter to tables
- **api-integration-layer** - Server-side filtering with React Query
- **state-management-setup** - Store filters in global state

---

**Version**: 1.0.0
**Category**: Frontend Extended
**Complexity**: Medium