Skip to content

Filtros y Búsqueda con TanStack Query

Los sistemas de filtrado y búsqueda son fundamentales en aplicaciones modernas. TanStack Query facilita la implementación de estas funcionalidades con gestión eficiente del cache y sincronización automática.

types/search.ts
export interface SearchFilters {
query: string;
category: string;
status: 'all' | 'active' | 'inactive';
dateRange: {
start: string;
end: string;
};
priceRange: {
min: number;
max: number;
};
tags: string[];
sortBy: 'name' | 'date' | 'price' | 'popularity';
sortOrder: 'asc' | 'desc';
}
export interface Product {
id: number;
name: string;
description: string;
price: number;
category: string;
status: 'active' | 'inactive';
tags: string[];
imageUrl: string;
rating: number;
createdAt: string;
updatedAt: string;
}
export interface SearchResponse {
products: Product[];
total: number;
facets: {
categories: Array<{ name: string; count: number; }>;
tags: Array<{ name: string; count: number; }>;
priceRanges: Array<{ range: string; count: number; }>;
};
}
api/search.ts
export const searchApi = {
searchProducts: async (filters: Partial<SearchFilters>): Promise<SearchResponse> => {
const params = new URLSearchParams();
// Construir parámetros de búsqueda
Object.entries(filters).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
if (typeof value === 'object') {
params.append(key, JSON.stringify(value));
} else {
params.append(key, value.toString());
}
}
});
const response = await fetch(`/api/products/search?${params}`);
if (!response.ok) {
throw new Error('Error en la búsqueda');
}
return response.json();
},
getSearchSuggestions: async (query: string): Promise<string[]> => {
if (!query.trim()) return [];
const response = await fetch(`/api/products/suggestions?q=${encodeURIComponent(query)}`);
if (!response.ok) {
throw new Error('Error al obtener sugerencias');
}
return response.json();
},
getFilterOptions: async (): Promise<{
categories: string[];
tags: string[];
priceRanges: Array<{ min: number; max: number; label: string; }>;
}> => {
const response = await fetch('/api/products/filter-options');
if (!response.ok) {
throw new Error('Error al obtener opciones de filtro');
}
return response.json();
},
};
hooks/useProductSearch.ts
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { searchApi } from '../api/search';
import type { SearchFilters } from '../types/search';
export const useProductSearch = (filters: Partial<SearchFilters>) => {
// Crear query key que incluya todos los filtros
const queryKey = useMemo(() => {
const cleanFilters = Object.fromEntries(
Object.entries(filters).filter(([_, value]) =>
value !== undefined &&
value !== null &&
value !== '' &&
!(Array.isArray(value) && value.length === 0)
)
);
return ['products', 'search', cleanFilters];
}, [filters]);
return useQuery({
queryKey,
queryFn: () => searchApi.searchProducts(filters),
enabled: true, // Siempre habilitado, incluso para búsquedas vacías
staleTime: 2 * 60 * 1000, // 2 minutos
gcTime: 5 * 60 * 1000, // 5 minutos
// Configuración para búsquedas reactivas
refetchOnWindowFocus: false,
retry: (failureCount, error) => {
// No reintentar para errores de validación
if (error.message.includes('400')) return false;
return failureCount < 2;
},
});
};
hooks/useSearchSuggestions.ts
import { useQuery } from '@tanstack/react-query';
import { useState, useEffect } from 'react';
import { searchApi } from '../api/search';
export const useSearchSuggestions = (query: string, delay: number = 300) => {
const [debouncedQuery, setDebouncedQuery] = useState(query);
// Debounce del query
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(query);
}, delay);
return () => clearTimeout(timer);
}, [query, delay]);
return useQuery({
queryKey: ['search', 'suggestions', debouncedQuery],
queryFn: () => searchApi.getSearchSuggestions(debouncedQuery),
enabled: debouncedQuery.length >= 2, // Solo buscar con 2 o más caracteres
staleTime: 5 * 60 * 1000, // 5 minutos (sugerencias cambian poco)
gcTime: 10 * 60 * 1000, // 10 minutos
});
};
hooks/useFilterOptions.ts
import { useQuery } from '@tanstack/react-query';
import { searchApi } from '../api/search';
export const useFilterOptions = () => {
return useQuery({
queryKey: ['filter-options'],
queryFn: searchApi.getFilterOptions,
staleTime: 30 * 60 * 1000, // 30 minutos (opciones cambian poco)
gcTime: 60 * 60 * 1000, // 1 hora
refetchOnWindowFocus: false,
});
};
components/ProductSearch.tsx
import React, { useState, useMemo } from 'react';
import { useProductSearch } from '../hooks/useProductSearch';
import { useFilterOptions } from '../hooks/useFilterOptions';
import { SearchBar } from './SearchBar';
import { FilterPanel } from './FilterPanel';
import { ProductGrid } from './ProductGrid';
import { SearchResults } from './SearchResults';
import type { SearchFilters } from '../types/search';
const initialFilters: SearchFilters = {
query: '',
category: '',
status: 'all',
dateRange: { start: '', end: '' },
priceRange: { min: 0, max: 10000 },
tags: [],
sortBy: 'name',
sortOrder: 'asc',
};
export const ProductSearch: React.FC = () => {
const [filters, setFilters] = useState<SearchFilters>(initialFilters);
const [showFilters, setShowFilters] = useState(false);
// Queries
const { data: searchResults, isLoading, error } = useProductSearch(filters);
const { data: filterOptions } = useFilterOptions();
// Calcular filtros activos
const activeFiltersCount = useMemo(() => {
let count = 0;
if (filters.query) count++;
if (filters.category) count++;
if (filters.status !== 'all') count++;
if (filters.tags.length > 0) count++;
if (filters.priceRange.min > 0 || filters.priceRange.max < 10000) count++;
if (filters.dateRange.start || filters.dateRange.end) count++;
return count;
}, [filters]);
const updateFilter = <K extends keyof SearchFilters>(
key: K,
value: SearchFilters[K]
) => {
setFilters(prev => ({ ...prev, [key]: value }));
};
const clearAllFilters = () => {
setFilters(initialFilters);
};
const clearFilter = (filterKey: keyof SearchFilters) => {
const clearedFilters = { ...filters };
switch (filterKey) {
case 'query':
clearedFilters.query = '';
break;
case 'category':
clearedFilters.category = '';
break;
case 'status':
clearedFilters.status = 'all';
break;
case 'tags':
clearedFilters.tags = [];
break;
case 'priceRange':
clearedFilters.priceRange = { min: 0, max: 10000 };
break;
case 'dateRange':
clearedFilters.dateRange = { start: '', end: '' };
break;
}
setFilters(clearedFilters);
};
return (
<div className="max-w-7xl mx-auto p-6">
{/* Header con búsqueda */}
<div className="mb-8">
<h1 className="text-3xl font-bold mb-6">Búsqueda de Productos</h1>
<SearchBar
query={filters.query}
onQueryChange={(query) => updateFilter('query', query)}
placeholder="Buscar productos..."
/>
{/* Controles de filtros */}
<div className="flex items-center justify-between mt-4">
<div className="flex items-center space-x-4">
<button
onClick={() => setShowFilters(!showFilters)}
className={`flex items-center px-4 py-2 rounded-md transition-colors ${
showFilters
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4" />
</svg>
Filtros
{activeFiltersCount > 0 && (
<span className="ml-2 bg-red-500 text-white text-xs rounded-full px-2 py-1">
{activeFiltersCount}
</span>
)}
</button>
{activeFiltersCount > 0 && (
<button
onClick={clearAllFilters}
className="text-red-600 hover:text-red-800 text-sm"
>
Limpiar filtros
</button>
)}
</div>
{/* Ordenamiento */}
<div className="flex items-center space-x-2">
<label className="text-sm text-gray-600">Ordenar por:</label>
<select
value={`${filters.sortBy}-${filters.sortOrder}`}
onChange={(e) => {
const [sortBy, sortOrder] = e.target.value.split('-');
updateFilter('sortBy', sortBy as SearchFilters['sortBy']);
updateFilter('sortOrder', sortOrder as SearchFilters['sortOrder']);
}}
className="border border-gray-300 rounded-md px-3 py-1 text-sm focus:ring-2 focus:ring-blue-500"
>
<option value="name-asc">Nombre (A-Z)</option>
<option value="name-desc">Nombre (Z-A)</option>
<option value="price-asc">Precio (Menor a Mayor)</option>
<option value="price-desc">Precio (Mayor a Menor)</option>
<option value="date-desc">Más Recientes</option>
<option value="popularity-desc">Más Populares</option>
</select>
</div>
</div>
</div>
<div className="flex gap-6">
{/* Panel de filtros */}
{showFilters && (
<div className="w-80 flex-shrink-0">
<FilterPanel
filters={filters}
onFilterChange={updateFilter}
onClearFilter={clearFilter}
filterOptions={filterOptions}
/>
</div>
)}
{/* Resultados */}
<div className="flex-1">
<SearchResults
results={searchResults}
isLoading={isLoading}
error={error}
filters={filters}
/>
</div>
</div>
</div>
);
};
components/SearchBar.tsx
import React, { useState, useRef, useEffect } from 'react';
import { useSearchSuggestions } from '../hooks/useSearchSuggestions';
interface SearchBarProps {
query: string;
onQueryChange: (query: string) => void;
placeholder?: string;
}
export const SearchBar: React.FC<SearchBarProps> = ({
query,
onQueryChange,
placeholder = "Buscar...",
}) => {
const [inputValue, setInputValue] = useState(query);
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const suggestionsRef = useRef<HTMLDivElement>(null);
const { data: suggestions = [], isLoading: loadingSuggestions } = useSearchSuggestions(inputValue);
// Sincronizar con prop externa
useEffect(() => {
setInputValue(query);
}, [query]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setInputValue(value);
setSelectedIndex(-1);
setShowSuggestions(true);
// Debounce manual para la búsqueda real
const timer = setTimeout(() => {
onQueryChange(value);
}, 300);
return () => clearTimeout(timer);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!showSuggestions || suggestions.length === 0) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex(prev =>
prev < suggestions.length - 1 ? prev + 1 : prev
);
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex(prev => prev > 0 ? prev - 1 : -1);
break;
case 'Enter':
e.preventDefault();
if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
const selectedSuggestion = suggestions[selectedIndex];
setInputValue(selectedSuggestion);
onQueryChange(selectedSuggestion);
setShowSuggestions(false);
}
break;
case 'Escape':
setShowSuggestions(false);
setSelectedIndex(-1);
break;
}
};
const handleSuggestionClick = (suggestion: string) => {
setInputValue(suggestion);
onQueryChange(suggestion);
setShowSuggestions(false);
inputRef.current?.focus();
};
const handleClear = () => {
setInputValue('');
onQueryChange('');
setShowSuggestions(false);
inputRef.current?.focus();
};
return (
<div className="relative">
<div className="relative">
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={() => setShowSuggestions(true)}
onBlur={() => {
// Delay para permitir clicks en sugerencias
setTimeout(() => setShowSuggestions(false), 200);
}}
placeholder={placeholder}
className="w-full pl-10 pr-10 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-lg"
/>
{/* Icono de búsqueda */}
<div className="absolute inset-y-0 left-0 pl-3 flex items-center">
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
{/* Botón limpiar */}
{inputValue && (
<button
onClick={handleClear}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
>
<svg className="h-5 w-5 text-gray-400 hover:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
{/* Indicador de carga */}
{loadingSuggestions && inputValue && (
<div className="absolute inset-y-0 right-10 flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
</div>
)}
</div>
{/* Sugerencias */}
{showSuggestions && suggestions.length > 0 && (
<div
ref={suggestionsRef}
className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-auto"
>
{suggestions.map((suggestion, index) => (
<button
key={suggestion}
onClick={() => handleSuggestionClick(suggestion)}
className={`w-full text-left px-4 py-2 hover:bg-gray-50 ${
selectedIndex === index ? 'bg-blue-50 text-blue-700' : ''
}`}
>
<span className="flex items-center">
<svg className="h-4 w-4 mr-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{suggestion}
</span>
</button>
))}
</div>
)}
</div>
);
};
components/FilterPanel.tsx
import React from 'react';
import type { SearchFilters } from '../types/search';
interface FilterPanelProps {
filters: SearchFilters;
onFilterChange: <K extends keyof SearchFilters>(key: K, value: SearchFilters[K]) => void;
onClearFilter: (key: keyof SearchFilters) => void;
filterOptions?: {
categories: string[];
tags: string[];
priceRanges: Array<{ min: number; max: number; label: string; }>;
};
}
export const FilterPanel: React.FC<FilterPanelProps> = ({
filters,
onFilterChange,
onClearFilter,
filterOptions,
}) => {
const handlePriceChange = (type: 'min' | 'max', value: number) => {
onFilterChange('priceRange', {
...filters.priceRange,
[type]: value,
});
};
const handleTagToggle = (tag: string) => {
const newTags = filters.tags.includes(tag)
? filters.tags.filter(t => t !== tag)
: [...filters.tags, tag];
onFilterChange('tags', newTags);
};
return (
<div className="bg-white p-6 rounded-lg shadow">
<h3 className="text-lg font-semibold mb-4">Filtros</h3>
{/* Categoría */}
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-gray-700">Categoría</label>
{filters.category && (
<button
onClick={() => onClearFilter('category')}
className="text-xs text-red-600 hover:text-red-800"
>
Limpiar
</button>
)}
</div>
<select
value={filters.category}
onChange={(e) => onFilterChange('category', e.target.value)}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500"
>
<option value="">Todas las categorías</option>
{filterOptions?.categories.map(category => (
<option key={category} value={category}>{category}</option>
))}
</select>
</div>
{/* Estado */}
<div className="mb-6">
<label className="text-sm font-medium text-gray-700 mb-2 block">Estado</label>
<div className="space-y-2">
{[
{ value: 'all', label: 'Todos' },
{ value: 'active', label: 'Activos' },
{ value: 'inactive', label: 'Inactivos' },
].map(option => (
<label key={option.value} className="flex items-center">
<input
type="radio"
value={option.value}
checked={filters.status === option.value}
onChange={(e) => onFilterChange('status', e.target.value as SearchFilters['status'])}
className="h-4 w-4 text-blue-600 focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-gray-700">{option.label}</span>
</label>
))}
</div>
</div>
{/* Rango de precios */}
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-gray-700">Precio</label>
{(filters.priceRange.min > 0 || filters.priceRange.max < 10000) && (
<button
onClick={() => onClearFilter('priceRange')}
className="text-xs text-red-600 hover:text-red-800"
>
Limpiar
</button>
)}
</div>
<div className="space-y-3">
<div>
<label className="text-xs text-gray-500">Mínimo</label>
<input
type="number"
value={filters.priceRange.min}
onChange={(e) => handlePriceChange('min', Number(e.target.value))}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500"
min={0}
max={filters.priceRange.max}
/>
</div>
<div>
<label className="text-xs text-gray-500">Máximo</label>
<input
type="number"
value={filters.priceRange.max}
onChange={(e) => handlePriceChange('max', Number(e.target.value))}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500"
min={filters.priceRange.min}
max={10000}
/>
</div>
<div className="text-xs text-gray-500">
${filters.priceRange.min} - ${filters.priceRange.max}
</div>
</div>
</div>
{/* Tags */}
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-gray-700">Etiquetas</label>
{filters.tags.length > 0 && (
<button
onClick={() => onClearFilter('tags')}
className="text-xs text-red-600 hover:text-red-800"
>
Limpiar
</button>
)}
</div>
<div className="space-y-2 max-h-40 overflow-y-auto">
{filterOptions?.tags.map(tag => (
<label key={tag} className="flex items-center">
<input
type="checkbox"
checked={filters.tags.includes(tag)}
onChange={() => handleTagToggle(tag)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{tag}</span>
</label>
))}
</div>
</div>
{/* Rango de fechas */}
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-gray-700">Fecha de creación</label>
{(filters.dateRange.start || filters.dateRange.end) && (
<button
onClick={() => onClearFilter('dateRange')}
className="text-xs text-red-600 hover:text-red-800"
>
Limpiar
</button>
)}
</div>
<div className="space-y-3">
<div>
<label className="text-xs text-gray-500">Desde</label>
<input
type="date"
value={filters.dateRange.start}
onChange={(e) => onFilterChange('dateRange', {
...filters.dateRange,
start: e.target.value,
})}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="text-xs text-gray-500">Hasta</label>
<input
type="date"
value={filters.dateRange.end}
onChange={(e) => onFilterChange('dateRange', {
...filters.dateRange,
end: e.target.value,
})}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
</div>
</div>
);
};
hooks/useUrlFilters.ts
import { useSearchParams } from 'react-router-dom';
import { useMemo } from 'react';
import type { SearchFilters } from '../types/search';
export const useUrlFilters = () => {
const [searchParams, setSearchParams] = useSearchParams();
const filters = useMemo(() => {
const params: Partial<SearchFilters> = {};
// Extraer parámetros de la URL
const query = searchParams.get('q');
if (query) params.query = query;
const category = searchParams.get('category');
if (category) params.category = category;
const status = searchParams.get('status') as SearchFilters['status'];
if (status && ['all', 'active', 'inactive'].includes(status)) {
params.status = status;
}
const tags = searchParams.get('tags');
if (tags) params.tags = tags.split(',');
const minPrice = searchParams.get('minPrice');
const maxPrice = searchParams.get('maxPrice');
if (minPrice || maxPrice) {
params.priceRange = {
min: minPrice ? Number(minPrice) : 0,
max: maxPrice ? Number(maxPrice) : 10000,
};
}
const startDate = searchParams.get('startDate');
const endDate = searchParams.get('endDate');
if (startDate || endDate) {
params.dateRange = {
start: startDate || '',
end: endDate || '',
};
}
const sortBy = searchParams.get('sortBy') as SearchFilters['sortBy'];
if (sortBy) params.sortBy = sortBy;
const sortOrder = searchParams.get('sortOrder') as SearchFilters['sortOrder'];
if (sortOrder) params.sortOrder = sortOrder;
return params;
}, [searchParams]);
const updateFilters = (newFilters: Partial<SearchFilters>) => {
const params = new URLSearchParams();
Object.entries(newFilters).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
if (key === 'tags' && Array.isArray(value)) {
if (value.length > 0) {
params.set('tags', value.join(','));
}
} else if (key === 'priceRange' && typeof value === 'object') {
if (value.min > 0) params.set('minPrice', value.min.toString());
if (value.max < 10000) params.set('maxPrice', value.max.toString());
} else if (key === 'dateRange' && typeof value === 'object') {
if (value.start) params.set('startDate', value.start);
if (value.end) params.set('endDate', value.end);
} else if (key === 'query') {
params.set('q', value.toString());
} else if (value !== 'all') {
params.set(key, value.toString());
}
}
});
setSearchParams(params);
};
return { filters, updateFilters };
};
  • Implementar debounce para evitar requests excesivos
  • Diferentes delays para diferentes tipos de input
  • Usar staleTime apropiado para diferentes tipos de búsqueda
  • Mantener sugerencias en cache por más tiempo
  • Feedback visual inmediato
  • Sugerencias de búsqueda contextual
  • Estados de carga apropiados
  • Prefetch de filtros comunes
  • Virtualización para listas grandes
  • Optimización de re-renders
  • Sincronización con URL para navegación directa
  • Preservar filtros entre sesiones cuando sea apropiado

Los sistemas de filtrado y búsqueda bien implementados mejoran significativamente la experiencia del usuario y la eficiencia de la aplicación.