Filtros y Búsqueda con TanStack Query
Filtros y Búsqueda con TanStack Query
Section titled “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.
Configuración Base
Section titled “Configuración Base”Tipos y Interfaces
Section titled “Tipos y Interfaces”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 de Búsqueda
Section titled “API de Búsqueda”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(); },};
Hook Principal de Búsqueda
Section titled “Hook Principal de Búsqueda”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; }, });};
Hook para Sugerencias con Debounce
Section titled “Hook para Sugerencias con Debounce”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 });};
Hook para Opciones de Filtros
Section titled “Hook para Opciones de Filtros”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, });};
Componente Principal de Búsqueda
Section titled “Componente Principal de Búsqueda”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> );};
Componente de Barra de Búsqueda
Section titled “Componente de Barra de Búsqueda”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> );};
Panel de Filtros Avanzado
Section titled “Panel de Filtros Avanzado”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> );};
Sincronización con URL
Section titled “Sincronización con URL”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 };};
Mejores Prácticas
Section titled “Mejores Prácticas”1. Debounce Inteligente
Section titled “1. Debounce Inteligente”- Implementar debounce para evitar requests excesivos
- Diferentes delays para diferentes tipos de input
2. Cache Estratégico
Section titled “2. Cache Estratégico”- Usar staleTime apropiado para diferentes tipos de búsqueda
- Mantener sugerencias en cache por más tiempo
3. Experiencia de Usuario
Section titled “3. Experiencia de Usuario”- Feedback visual inmediato
- Sugerencias de búsqueda contextual
- Estados de carga apropiados
4. Performance
Section titled “4. Performance”- Prefetch de filtros comunes
- Virtualización para listas grandes
- Optimización de re-renders
5. Persistencia
Section titled “5. Persistencia”- 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.