🔍 Lesson 6.4: Search and Query Parameters
Master URL query parameters to build powerful search, filter, and pagination features. Learn to sync application state with the URL, making your app shareable, bookmarkable, and SEO-friendly with the useSearchParams hook.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Use the useSearchParams hook to read and write URL query parameters
- Build search functionality with URL state management
- Implement filters that sync with the URL
- Create pagination using query parameters
- Type search parameters properly with TypeScript
- Handle complex URL state with multiple parameters
- Build shareable and bookmarkable application states
- Understand URL state vs component state trade-offs
Estimated Time: 75-90 minutes
Prerequisites: Lessons 6.1-6.3 (React Router fundamentals, navigation, and protection)
📑 In This Lesson
🌐 Understanding URL Query Parameters
URL query parameters (also called search parameters or query strings) are key-value pairs that appear after the ? in a URL. They're a powerful way to store and share application state.
Anatomy of a URL with Query Parameters
https://example.com/products?category=electronics&sort=price&order=asc&page=2
│ │ │ │
│ │ └─ Query String (search parameters)
│ └─ Pathname
└─ Base URL
📖 Query Parameter Structure
- ? - Separates pathname from query string
- key=value - Parameter name and value
- & - Separates multiple parameters
- Encoding - Special characters are URL-encoded (space = %20)
Why Use Query Parameters?
| Benefit | Description | Example Use Case |
|---|---|---|
| Shareable | URLs can be copied and shared | Share search results with colleagues |
| Bookmarkable | Users can bookmark specific states | Save favorite filter combinations |
| Browser History | Back/forward buttons work naturally | Navigate through search history |
| SEO Friendly | Search engines can index different states | Product listings with filters |
| Deep Linking | Link directly to specific app states | Email notifications with context |
| Persistent | Survives page refreshes | Maintain filters after reload |
URL State vs Component State
When to Use URL Parameters
✅ Good Candidates for URL State
- Search queries - What the user searched for
- Filters - Category, price range, ratings
- Sorting - Sort field and order
- Pagination - Current page number
- View modes - List vs grid, expanded vs collapsed
- Tab selection - Active tab in multi-tab interfaces
- Date ranges - Start and end dates for reports
❌ Poor Candidates for URL State
- Passwords - Never put sensitive data in URLs
- Form input - Partial form data during editing
- Temporary UI - Modal open/closed, hover states
- Large data - Entire objects or arrays
- Private data - User IDs, session tokens
- Animation state - Purely visual, transient states
Common Query Parameter Patterns
// Search
/products?q=laptop
// Single filter
/products?category=electronics
// Multiple filters
/products?category=electronics&brand=apple&minPrice=500
// Sorting
/products?sort=price&order=desc
// Pagination
/products?page=2&limit=20
// Combined
/products?q=laptop&category=electronics&sort=price&order=asc&page=1
// Arrays (multiple values for same key)
/products?tags=new&tags=sale&tags=featured
// or
/products?tags=new,sale,featured
// Boolean flags
/products?inStock=true&onSale=true
🔧 The useSearchParams Hook
React Router provides the useSearchParams hook to read and manipulate URL query parameters. It works similarly to useState, but syncs with the URL.
Basic useSearchParams Usage
The hook returns a tuple: the current search params and a function to update them:
import { useSearchParams } from 'react-router-dom';
function SearchExample() {
const [searchParams, setSearchParams] = useSearchParams();
// Read a parameter
const query = searchParams.get('q');
// Read with default value
const page = searchParams.get('page') || '1';
return (
<div>
<p>Search query: {query}</p>
<p>Current page: {page}</p>
</div>
);
}
📖 SearchParams API
The searchParams object (URLSearchParams) provides:
get(key)- Get a single value (or null)getAll(key)- Get all values for a key (array)has(key)- Check if parameter existsset(key, value)- Set a parameterdelete(key)- Remove a parametertoString()- Convert to query string
Reading Query Parameters
function ProductList() {
const [searchParams] = useSearchParams();
// Get single values
const category = searchParams.get('category'); // "electronics" or null
const sortBy = searchParams.get('sort'); // "price" or null
// Get with default
const page = searchParams.get('page') ?? '1';
const limit = searchParams.get('limit') ?? '10';
// Check existence
const hasFilters = searchParams.has('category') || searchParams.has('brand');
// Get all values for a key (for arrays)
const tags = searchParams.getAll('tags'); // ["new", "sale", "featured"]
return (
<div>
<h1>Products</h1>
{category && <p>Category: {category}</p>}
{sortBy && <p>Sorted by: {sortBy}</p>}
<p>Page {page} of results</p>
</div>
);
}
Setting Query Parameters
Update parameters using the setter function:
function SearchBar() {
const [searchParams, setSearchParams] = useSearchParams();
const [query, setQuery] = useState('');
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
// Option 1: Set individual parameter
setSearchParams({ q: query });
// Option 2: Update existing params
const newParams = new URLSearchParams(searchParams);
newParams.set('q', query);
newParams.set('page', '1'); // Reset to page 1 on new search
setSearchParams(newParams);
};
return (
<form onSubmit={handleSearch}>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
<button type="submit">Search</button>
</form>
);
}
Preserving Existing Parameters
When updating parameters, you often want to keep existing ones:
function CategoryFilter() {
const [searchParams, setSearchParams] = useSearchParams();
const handleCategoryChange = (category: string) => {
// Create new URLSearchParams from current params
const newParams = new URLSearchParams(searchParams);
// Update only the category
newParams.set('category', category);
// Reset page to 1 when changing filters
newParams.set('page', '1');
// Apply the updated params
setSearchParams(newParams);
};
return (
<div>
<button onClick={() => handleCategoryChange('electronics')}>
Electronics
</button>
<button onClick={() => handleCategoryChange('books')}>
Books
</button>
<button onClick={() => handleCategoryChange('clothing')}>
Clothing
</button>
</div>
);
}
Removing Query Parameters
function FilterControls() {
const [searchParams, setSearchParams] = useSearchParams();
const clearCategory = () => {
const newParams = new URLSearchParams(searchParams);
newParams.delete('category');
setSearchParams(newParams);
};
const clearAllFilters = () => {
// Remove all filter-related params
const newParams = new URLSearchParams(searchParams);
newParams.delete('category');
newParams.delete('brand');
newParams.delete('minPrice');
newParams.delete('maxPrice');
setSearchParams(newParams);
};
const resetToDefaults = () => {
// Start fresh with only specific params
setSearchParams({ page: '1', limit: '10' });
};
return (
<div>
<button onClick={clearCategory}>Clear Category</button>
<button onClick={clearAllFilters}>Clear All Filters</button>
<button onClick={resetToDefaults}>Reset</button>
</div>
);
}
Replace vs Push Navigation
Control whether parameter changes create new history entries:
function SearchExample() {
const [searchParams, setSearchParams] = useSearchParams();
const updateSearch = (query: string) => {
// Default: adds to browser history (can go back)
setSearchParams({ q: query });
// Replace: doesn't add to history (replaces current entry)
setSearchParams({ q: query }, { replace: true });
};
// Use replace for:
// - Filters (users don't want to click back through each filter)
// - Pagination (going back shouldn't go page by page)
// - Real-time updates (like search-as-you-type)
// Use push (default) for:
// - Explicit search submissions
// - Navigation between distinct views
return <div>Search Example</div>;
}
💡 Navigation Behavior Tips
- Use replace for filters - Users don't want to back through each filter change
- Use replace for pagination - Avoid backing through pages
- Use push for searches - Allow users to revisit previous searches
- Be consistent - Don't mix behaviors for similar actions
Working with Multiple Parameters
function AdvancedFilters() {
const [searchParams, setSearchParams] = useSearchParams();
const updateMultipleParams = (updates: Record<string, string>) => {
const newParams = new URLSearchParams(searchParams);
// Update multiple parameters at once
Object.entries(updates).forEach(([key, value]) => {
if (value) {
newParams.set(key, value);
} else {
newParams.delete(key);
}
});
setSearchParams(newParams, { replace: true });
};
const handleFilterSubmit = (filters: {
category?: string;
minPrice?: string;
maxPrice?: string;
brand?: string;
}) => {
updateMultipleParams({
category: filters.category || '',
minPrice: filters.minPrice || '',
maxPrice: filters.maxPrice || '',
brand: filters.brand || '',
page: '1' // Reset pagination
});
};
return <div>Filters</div>;
}
🏋️ Exercise: Basic Search Parameters
Create a simple product search interface that uses URL query parameters.
Requirements:
- Create a search input that updates the
qparameter - Display the current search query from the URL
- Add a "Clear Search" button that removes the
qparameter - Use replace navigation for the search updates
- Show "No search query" when the parameter is empty
💡 Hint
Use the useSearchParams hook and handle form submission:
const [searchParams, setSearchParams] = useSearchParams();
const query = searchParams.get('q') || '';
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setSearchParams({ q: inputValue }, { replace: true });
};
✅ Solution
import { useSearchParams } from 'react-router-dom';
import { useState, useEffect } from 'react';
function ProductSearch() {
const [searchParams, setSearchParams] = useSearchParams();
const [inputValue, setInputValue] = useState('');
// Get current search query from URL
const currentQuery = searchParams.get('q') || '';
// Sync input with URL on mount and when URL changes
useEffect(() => {
setInputValue(currentQuery);
}, [currentQuery]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (inputValue.trim()) {
setSearchParams({ q: inputValue.trim() }, { replace: true });
}
};
const handleClear = () => {
setInputValue('');
setSearchParams({}, { replace: true });
};
return (
<div>
<form onSubmit={handleSubmit}>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Search products..."
/>
<button type="submit">Search</button>
{currentQuery && (
<button type="button" onClick={handleClear}>
Clear Search
</button>
)}
</form>
<div className="search-status">
{currentQuery ? (
<p>Searching for: <strong>{currentQuery}</strong></p>
) : (
<p>No search query</p>
)}
</div>
</div>
);
}
🔎 Building Search Functionality
Let's build a complete search feature that syncs with URL parameters, handles debouncing, and provides a great user experience.
Simple Search Implementation
A basic search that updates the URL on form submission:
// components/SearchBar.tsx
import { useSearchParams } from 'react-router-dom';
import { useState, FormEvent } from 'react';
export function SearchBar() {
const [searchParams, setSearchParams] = useSearchParams();
const [query, setQuery] = useState(searchParams.get('q') || '');
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (query.trim()) {
// Preserve other params, update search
const newParams = new URLSearchParams(searchParams);
newParams.set('q', query.trim());
newParams.set('page', '1'); // Reset to first page
setSearchParams(newParams);
} else {
// Remove search param if empty
const newParams = new URLSearchParams(searchParams);
newParams.delete('q');
setSearchParams(newParams);
}
};
const handleClear = () => {
setQuery('');
const newParams = new URLSearchParams(searchParams);
newParams.delete('q');
setSearchParams(newParams);
};
return (
<form onSubmit={handleSubmit} className="search-bar">
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
aria-label="Search"
/>
<button type="submit">
🔍 Search
</button>
{query && (
<button type="button" onClick={handleClear}>
✕ Clear
</button>
)}
</form>
);
}
Search with Debouncing
Implement search-as-you-type with debouncing to avoid excessive updates:
// hooks/useDebounce.ts
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
// components/DebouncedSearch.tsx
import { useSearchParams } from 'react-router-dom';
import { useState, useEffect } from 'react';
import { useDebounce } from '../hooks/useDebounce';
export function DebouncedSearch() {
const [searchParams, setSearchParams] = useSearchParams();
const [query, setQuery] = useState(searchParams.get('q') || '');
// Debounce the query by 500ms
const debouncedQuery = useDebounce(query, 500);
// Update URL when debounced query changes
useEffect(() => {
const newParams = new URLSearchParams(searchParams);
if (debouncedQuery.trim()) {
newParams.set('q', debouncedQuery.trim());
newParams.set('page', '1');
} else {
newParams.delete('q');
}
// Use replace to avoid cluttering history
setSearchParams(newParams, { replace: true });
}, [debouncedQuery]);
return (
<div className="search-container">
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search as you type..."
aria-label="Search"
/>
{query !== debouncedQuery && (
<span className="searching-indicator">Searching...</span>
)}
</div>
);
}
Search Results Component
Display results based on the search query from the URL:
// pages/SearchResults.tsx
import { useSearchParams } from 'react-router-dom';
import { useState, useEffect } from 'react';
interface Product {
id: number;
name: string;
category: string;
price: number;
}
export function SearchResults() {
const [searchParams] = useSearchParams();
const query = searchParams.get('q') || '';
const [results, setResults] = useState<Product[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!query) {
setResults([]);
return;
}
const fetchResults = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(
`/api/products/search?q=${encodeURIComponent(query)}`
);
if (!response.ok) {
throw new Error('Search failed');
}
const data = await response.json();
setResults(data.products);
} catch (err) {
setError('Failed to load search results');
console.error(err);
} finally {
setIsLoading(false);
}
};
fetchResults();
}, [query]);
if (!query) {
return (
<div className="search-empty">
<p>Enter a search query to find products</p>
</div>
);
}
if (isLoading) {
return <div className="loading">Searching...</div>;
}
if (error) {
return <div className="error">{error}</div>;
}
return (
<div className="search-results">
<h2>
Results for "{query}" ({results.length} found)
</h2>
{results.length === 0 ? (
<div className="no-results">
<p>No products found for "{query}"</p>
<p>Try a different search term</p>
</div>
) : (
<div className="results-grid">
{results.map(product => (
<div key={product.id} className="product-card">
<h3>{product.name}</h3>
<p>{product.category}</p>
<p className="price">${product.price}</p>
</div>
))}
</div>
)}
</div>
);
}
Search Suggestions
Add autocomplete suggestions that appear as the user types:
// components/SearchWithSuggestions.tsx
import { useSearchParams } from 'react-router-dom';
import { useState, useEffect, useRef } from 'react';
interface Suggestion {
id: number;
text: string;
category: string;
}
export function SearchWithSuggestions() {
const [searchParams, setSearchParams] = useSearchParams();
const [query, setQuery] = useState(searchParams.get('q') || '');
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
// Fetch suggestions
useEffect(() => {
if (query.length < 2) {
setSuggestions([]);
return;
}
const fetchSuggestions = async () => {
try {
const response = await fetch(
`/api/search/suggestions?q=${encodeURIComponent(query)}`
);
const data = await response.json();
setSuggestions(data.suggestions);
} catch (err) {
console.error('Failed to fetch suggestions:', err);
}
};
const timeoutId = setTimeout(fetchSuggestions, 300);
return () => clearTimeout(timeoutId);
}, [query]);
const handleSubmit = (searchQuery: string) => {
const newParams = new URLSearchParams(searchParams);
newParams.set('q', searchQuery.trim());
newParams.set('page', '1');
setSearchParams(newParams);
setShowSuggestions(false);
};
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) {
handleSubmit(suggestions[selectedIndex].text);
} else {
handleSubmit(query);
}
break;
case 'Escape':
setShowSuggestions(false);
break;
}
};
return (
<div className="search-with-suggestions">
<input
ref={inputRef}
type="search"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setShowSuggestions(true);
setSelectedIndex(-1);
}}
onFocus={() => setShowSuggestions(true)}
onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
onKeyDown={handleKeyDown}
placeholder="Search products..."
/>
{showSuggestions && suggestions.length > 0 && (
<ul className="suggestions-list">
{suggestions.map((suggestion, index) => (
<li
key={suggestion.id}
className={index === selectedIndex ? 'selected' : ''}
onClick={() => handleSubmit(suggestion.text)}
>
<span className="suggestion-text">{suggestion.text}</span>
<span className="suggestion-category">
in {suggestion.category}
</span>
</li>
))}
</ul>
)}
</div>
);
}
✅ Search Implementation Best Practices
- Debounce user input - Avoid excessive API calls
- Show loading states - Let users know search is happening
- Handle empty results - Provide helpful messaging
- URL encode queries - Handle special characters properly
- Reset pagination - Go to page 1 on new search
- Preserve other filters - Keep existing parameters when searching
- Use replace navigation - Avoid cluttering browser history
🎛️ Implementing Filters with URL State
Filters are perfect candidates for URL state because users want to share and bookmark filtered views. Let's build a comprehensive filtering system.
Single Select Filter
A dropdown filter for selecting one option:
// components/CategoryFilter.tsx
import { useSearchParams } from 'react-router-dom';
interface CategoryFilterProps {
categories: string[];
}
export function CategoryFilter({ categories }: CategoryFilterProps) {
const [searchParams, setSearchParams] = useSearchParams();
const currentCategory = searchParams.get('category') || 'all';
const handleCategoryChange = (category: string) => {
const newParams = new URLSearchParams(searchParams);
if (category === 'all') {
newParams.delete('category');
} else {
newParams.set('category', category);
}
// Reset to page 1 when filter changes
newParams.set('page', '1');
setSearchParams(newParams, { replace: true });
};
return (
<div className="category-filter">
<label htmlFor="category">Category:</label>
<select
id="category"
value={currentCategory}
onChange={(e) => handleCategoryChange(e.target.value)}
>
<option value="all">All Categories</option>
{categories.map(category => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
</div>
);
}
Multi-Select Filter
Allow users to select multiple filter values:
// components/BrandFilter.tsx
import { useSearchParams } from 'react-router-dom';
interface BrandFilterProps {
brands: string[];
}
export function BrandFilter({ brands }: BrandFilterProps) {
const [searchParams, setSearchParams] = useSearchParams();
// Get selected brands from URL
const selectedBrands = searchParams.getAll('brand');
const handleBrandToggle = (brand: string) => {
const newParams = new URLSearchParams(searchParams);
// Remove all existing brand params
newParams.delete('brand');
// Determine new selection
const newSelection = selectedBrands.includes(brand)
? selectedBrands.filter(b => b !== brand) // Remove if selected
: [...selectedBrands, brand]; // Add if not selected
// Add updated brands
newSelection.forEach(b => {
newParams.append('brand', b);
});
// Reset pagination
newParams.set('page', '1');
setSearchParams(newParams, { replace: true });
};
return (
<div className="brand-filter">
<h3>Brands</h3>
{brands.map(brand => (
<label key={brand} className="checkbox-label">
<input
type="checkbox"
checked={selectedBrands.includes(brand)}
onChange={() => handleBrandToggle(brand)}
/>
{brand}
</label>
))}
</div>
);
}
Range Filter
Filter by numeric ranges like price:
// components/PriceRangeFilter.tsx
import { useSearchParams } from 'react-router-dom';
import { useState, useEffect } from 'react';
export function PriceRangeFilter() {
const [searchParams, setSearchParams] = useSearchParams();
const [minPrice, setMinPrice] = useState(
searchParams.get('minPrice') || ''
);
const [maxPrice, setMaxPrice] = useState(
searchParams.get('maxPrice') || ''
);
const applyPriceFilter = () => {
const newParams = new URLSearchParams(searchParams);
if (minPrice) {
newParams.set('minPrice', minPrice);
} else {
newParams.delete('minPrice');
}
if (maxPrice) {
newParams.set('maxPrice', maxPrice);
} else {
newParams.delete('maxPrice');
}
newParams.set('page', '1');
setSearchParams(newParams, { replace: true });
};
const clearPriceFilter = () => {
const newParams = new URLSearchParams(searchParams);
newParams.delete('minPrice');
newParams.delete('maxPrice');
setSearchParams(newParams, { replace: true });
setMinPrice('');
setMaxPrice('');
};
return (
<div className="price-range-filter">
<h3>Price Range</h3>
<div className="price-inputs">
<input
type="number"
placeholder="Min"
value={minPrice}
onChange={(e) => setMinPrice(e.target.value)}
min="0"
/>
<span>to</span>
<input
type="number"
placeholder="Max"
value={maxPrice}
onChange={(e) => setMaxPrice(e.target.value)}
min="0"
/>
</div>
<div className="filter-actions">
<button onClick={applyPriceFilter}>
Apply
</button>
<button onClick={clearPriceFilter}>
Clear
</button>
</div>
</div>
);
}
Boolean Filter
Simple on/off filters:
// components/AvailabilityFilter.tsx
import { useSearchParams } from 'react-router-dom';
export function AvailabilityFilter() {
const [searchParams, setSearchParams] = useSearchParams();
const inStock = searchParams.get('inStock') === 'true';
const onSale = searchParams.get('onSale') === 'true';
const toggleFilter = (key: string, currentValue: boolean) => {
const newParams = new URLSearchParams(searchParams);
if (currentValue) {
// Turn off - remove parameter
newParams.delete(key);
} else {
// Turn on - set to true
newParams.set(key, 'true');
}
newParams.set('page', '1');
setSearchParams(newParams, { replace: true });
};
return (
<div className="availability-filter">
<h3>Availability</h3>
<label className="checkbox-label">
<input
type="checkbox"
checked={inStock}
onChange={() => toggleFilter('inStock', inStock)}
/>
In Stock Only
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={onSale}
onChange={() => toggleFilter('onSale', onSale)}
/>
On Sale
</label>
</div>
);
}
Active Filters Display
Show currently applied filters with ability to remove them:
// components/ActiveFilters.tsx
import { useSearchParams } from 'react-router-dom';
export function ActiveFilters() {
const [searchParams, setSearchParams] = useSearchParams();
const removeFilter = (key: string, value?: string) => {
const newParams = new URLSearchParams(searchParams);
if (value) {
// For multi-value params, remove specific value
const values = searchParams.getAll(key);
newParams.delete(key);
values
.filter(v => v !== value)
.forEach(v => newParams.append(key, v));
} else {
// Remove entire parameter
newParams.delete(key);
}
setSearchParams(newParams, { replace: true });
};
const clearAllFilters = () => {
// Keep only non-filter params (like page, limit)
const newParams = new URLSearchParams();
const page = searchParams.get('page');
const limit = searchParams.get('limit');
if (page) newParams.set('page', page);
if (limit) newParams.set('limit', limit);
setSearchParams(newParams, { replace: true });
};
// Build list of active filters
const activeFilters: Array<{ key: string; value: string; label: string }> = [];
const category = searchParams.get('category');
if (category) {
activeFilters.push({
key: 'category',
value: '',
label: `Category: ${category}`
});
}
const brands = searchParams.getAll('brand');
brands.forEach(brand => {
activeFilters.push({
key: 'brand',
value: brand,
label: `Brand: ${brand}`
});
});
const minPrice = searchParams.get('minPrice');
const maxPrice = searchParams.get('maxPrice');
if (minPrice || maxPrice) {
const priceLabel = `Price: $${minPrice || '0'} - $${maxPrice || '∞'}`;
activeFilters.push({
key: 'minPrice',
value: '',
label: priceLabel
});
}
if (activeFilters.length === 0) {
return null;
}
return (
<div className="active-filters">
<h3>Active Filters:</h3>
<div className="filter-tags">
{activeFilters.map((filter, index) => (
<span key={index} className="filter-tag">
{filter.label}
<button
onClick={() => removeFilter(filter.key, filter.value)}
aria-label={`Remove ${filter.label}`}
>
✕
</button>
</span>
))}
</div>
<button onClick={clearAllFilters} className="clear-all">
Clear All Filters
</button>
</div>
);
}
Complete Filter Panel
Combine all filters into a cohesive filtering interface:
// components/FilterPanel.tsx
import { useSearchParams } from 'react-router-dom';
import { CategoryFilter } from './CategoryFilter';
import { BrandFilter } from './BrandFilter';
import { PriceRangeFilter } from './PriceRangeFilter';
import { AvailabilityFilter } from './AvailabilityFilter';
import { ActiveFilters } from './ActiveFilters';
const CATEGORIES = ['Electronics', 'Books', 'Clothing', 'Home & Garden'];
const BRANDS = ['Apple', 'Samsung', 'Sony', 'Nike', 'Adidas'];
export function FilterPanel() {
const [searchParams] = useSearchParams();
// Count active filters
const activeFilterCount = Array.from(searchParams.keys())
.filter(key => !['page', 'limit', 'sort', 'order'].includes(key))
.length;
return (
<aside className="filter-panel">
<div className="filter-header">
<h2>Filters</h2>
{activeFilterCount > 0 && (
<span className="filter-count">
{activeFilterCount} active
</span>
)}
</div>
<ActiveFilters />
<div className="filter-sections">
<CategoryFilter categories={CATEGORIES} />
<BrandFilter brands={BRANDS} />
<PriceRangeFilter />
<AvailabilityFilter />
</div>
</aside>
);
}
✅ Filter Implementation Best Practices
- Reset pagination - Always set page to 1 when filters change
- Use replace navigation - Don't create history entries for each filter
- Show active filters - Let users see and remove current filters
- Preserve other params - Don't lose search query when filtering
- Handle empty states - Clear params when filter is removed
- Provide clear UI - Make it obvious which filters are active
🏋️ Exercise: Build Product Filter
Create a product listing page with multiple filters that sync with URL parameters.
Requirements:
- Add a category dropdown filter (single select)
- Add a price range filter (min and max inputs)
- Add an "In Stock" checkbox filter
- Display active filters with ability to remove them
- Show product count based on applied filters
- All filters should update the URL and preserve each other
💡 Hint
Structure your filter updates to preserve existing parameters:
const updateFilter = (key: string, value: string | null) => {
const newParams = new URLSearchParams(searchParams);
if (value) {
newParams.set(key, value);
} else {
newParams.delete(key);
}
newParams.set('page', '1');
setSearchParams(newParams, { replace: true });
};
✅ Solution
// See complete implementation in downloadable course files
📄 Pagination with Query Parameters
Pagination is a perfect use case for URL parameters because it allows users to bookmark specific pages and share links to exact result sets.
Basic Pagination Component
// components/Pagination.tsx
import { useSearchParams } from 'react-router-dom';
interface PaginationProps {
totalPages: number;
currentPage: number;
}
export function Pagination({ totalPages, currentPage }: PaginationProps) {
const [searchParams, setSearchParams] = useSearchParams();
const goToPage = (page: number) => {
const newParams = new URLSearchParams(searchParams);
newParams.set('page', page.toString());
setSearchParams(newParams, { replace: true });
};
const goToNextPage = () => {
if (currentPage < totalPages) {
goToPage(currentPage + 1);
}
};
const goToPrevPage = () => {
if (currentPage > 1) {
goToPage(currentPage - 1);
}
};
return (
<nav className="pagination" aria-label="Pagination">
<button
onClick={goToPrevPage}
disabled={currentPage === 1}
aria-label="Previous page"
>
← Previous
</button>
<span className="page-info">
Page {currentPage} of {totalPages}
</span>
<button
onClick={goToNextPage}
disabled={currentPage === totalPages}
aria-label="Next page"
>
Next →
</button>
</nav>
);
}
Advanced Pagination with Page Numbers
// components/AdvancedPagination.tsx
import { useSearchParams } from 'react-router-dom';
interface AdvancedPaginationProps {
totalPages: number;
currentPage: number;
maxVisible?: number; // Maximum page numbers to show
}
export function AdvancedPagination({
totalPages,
currentPage,
maxVisible = 7
}: AdvancedPaginationProps) {
const [searchParams, setSearchParams] = useSearchParams();
const goToPage = (page: number) => {
const newParams = new URLSearchParams(searchParams);
newParams.set('page', page.toString());
setSearchParams(newParams, { replace: true });
};
// Calculate which page numbers to show
const getPageNumbers = () => {
if (totalPages <= maxVisible) {
// Show all pages
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
const halfVisible = Math.floor(maxVisible / 2);
let start = currentPage - halfVisible;
let end = currentPage + halfVisible;
// Adjust if at the beginning
if (start < 1) {
start = 1;
end = maxVisible;
}
// Adjust if at the end
if (end > totalPages) {
end = totalPages;
start = totalPages - maxVisible + 1;
}
const pages: Array<number | string> = [];
// Add first page and ellipsis if needed
if (start > 1) {
pages.push(1);
if (start > 2) {
pages.push('...');
}
}
// Add visible pages
for (let i = start; i <= end; i++) {
pages.push(i);
}
// Add ellipsis and last page if needed
if (end < totalPages) {
if (end < totalPages - 1) {
pages.push('...');
}
pages.push(totalPages);
}
return pages;
};
const pageNumbers = getPageNumbers();
return (
<nav className="pagination" aria-label="Pagination">
<button
onClick={() => goToPage(currentPage - 1)}
disabled={currentPage === 1}
aria-label="Previous page"
>
← Prev
</button>
<div className="page-numbers">
{pageNumbers.map((page, index) => {
if (page === '...') {
return (
<span key={`ellipsis-${index}`} className="ellipsis">
...
</span>
);
}
return (
<button
key={page}
onClick={() => goToPage(page as number)}
className={currentPage === page ? 'active' : ''}
aria-label={`Page ${page}`}
aria-current={currentPage === page ? 'page' : undefined}
>
{page}
</button>
);
})}
</div>
<button
onClick={() => goToPage(currentPage + 1)}
disabled={currentPage === totalPages}
aria-label="Next page"
>
Next →
</button>
</nav>
);
}
Items Per Page Selector
// components/ItemsPerPage.tsx
import { useSearchParams } from 'react-router-dom';
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
export function ItemsPerPage() {
const [searchParams, setSearchParams] = useSearchParams();
const currentLimit = parseInt(searchParams.get('limit') || '20');
const handleLimitChange = (limit: number) => {
const newParams = new URLSearchParams(searchParams);
newParams.set('limit', limit.toString());
newParams.set('page', '1'); // Reset to first page
setSearchParams(newParams, { replace: true });
};
return (
<div className="items-per-page">
<label htmlFor="limit">Items per page:</label>
<select
id="limit"
value={currentLimit}
onChange={(e) => handleLimitChange(Number(e.target.value))}
>
{PAGE_SIZE_OPTIONS.map(size => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</div>
);
}
Paginated Results Component
// components/PaginatedResults.tsx
import { useSearchParams } from 'react-router-dom';
import { useState, useEffect } from 'react';
import { Pagination } from './Pagination';
import { ItemsPerPage } from './ItemsPerPage';
interface Product {
id: number;
name: string;
price: number;
}
interface PaginatedResponse {
products: Product[];
totalCount: number;
page: number;
limit: number;
}
export function PaginatedResults() {
const [searchParams] = useSearchParams();
// Get pagination params from URL
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '20');
const [data, setData] = useState<PaginatedResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
try {
// Build query string with all params
const queryString = searchParams.toString();
const response = await fetch(`/api/products?${queryString}`);
const result = await response.json();
setData(result);
} catch (error) {
console.error('Failed to fetch data:', error);
} finally {
setIsLoading(false);
}
};
fetchData();
}, [searchParams]); // Re-fetch when any param changes
if (isLoading) {
return <div>Loading...</div>;
}
if (!data) {
return <div>No data available</div>;
}
const totalPages = Math.ceil(data.totalCount / limit);
const startItem = (page - 1) * limit + 1;
const endItem = Math.min(page * limit, data.totalCount);
return (
<div className="paginated-results">
<div className="results-header">
<p>
Showing {startItem}-{endItem} of {data.totalCount} results
</p>
<ItemsPerPage />
</div>
<div className="results-grid">
{data.products.map(product => (
<div key={product.id} className="product-card">
<h3>{product.name}</h3>
<p>${product.price}</p>
</div>
))}
</div>
{totalPages > 1 && (
<Pagination
totalPages={totalPages}
currentPage={page}
/>
)}
</div>
);
}
Infinite Scroll with URL State
Combine infinite scroll with URL state for the best of both worlds:
// components/InfiniteScrollResults.tsx
import { useSearchParams } from 'react-router-dom';
import { useState, useEffect, useRef } from 'react';
export function InfiniteScrollResults() {
const [searchParams, setSearchParams] = useSearchParams();
const [items, setItems] = useState<Product[]>([]);
const [hasMore, setHasMore] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const observerRef = useRef<HTMLDivElement>(null);
const page = parseInt(searchParams.get('page') || '1');
const loadMore = () => {
const newParams = new URLSearchParams(searchParams);
newParams.set('page', (page + 1).toString());
setSearchParams(newParams, { replace: true });
};
useEffect(() => {
const fetchPage = async () => {
setIsLoading(true);
try {
const response = await fetch(
`/api/products?${searchParams.toString()}`
);
const data = await response.json();
if (page === 1) {
// First page - replace items
setItems(data.products);
} else {
// Subsequent pages - append items
setItems(prev => [...prev, ...data.products]);
}
setHasMore(data.hasMore);
} catch (error) {
console.error('Failed to fetch:', error);
} finally {
setIsLoading(false);
}
};
fetchPage();
}, [searchParams]);
// Intersection Observer for infinite scroll
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !isLoading) {
loadMore();
}
},
{ threshold: 0.1 }
);
if (observerRef.current) {
observer.observe(observerRef.current);
}
return () => observer.disconnect();
}, [hasMore, isLoading, page]);
return (
<div>
<div className="results-grid">
{items.map(item => (
<div key={item.id} className="product-card">
{/* Product card content */}
</div>
))}
</div>
{hasMore && (
<div ref={observerRef} className="loading-trigger">
{isLoading && <div>Loading more...</div>}
</div>
)}
{!hasMore && items.length > 0 && (
<p>You've reached the end!</p>
)}
</div>
);
}
✅ Pagination Best Practices
- Use replace navigation - Don't clutter history with page changes
- Reset to page 1 - When filters or search changes
- Show page info - Let users know where they are
- Disable invalid actions - Prev on page 1, next on last page
- Make pages bookmarkable - Users can share links to specific pages
- Handle edge cases - What if totalPages changes?
🔤 Typing Search Parameters
TypeScript helps ensure type safety when working with URL parameters. Let's explore patterns for typing search parameters effectively.
Basic Type-Safe Parameter Reading
// Define expected parameters
interface ProductFilters {
category?: string;
brand?: string[];
minPrice?: number;
maxPrice?: number;
inStock?: boolean;
page?: number;
limit?: number;
}
function useProductFilters(): ProductFilters {
const [searchParams] = useSearchParams();
return {
category: searchParams.get('category') || undefined,
brand: searchParams.getAll('brand'),
minPrice: searchParams.get('minPrice')
? Number(searchParams.get('minPrice'))
: undefined,
maxPrice: searchParams.get('maxPrice')
? Number(searchParams.get('maxPrice'))
: undefined,
inStock: searchParams.get('inStock') === 'true' || undefined,
page: Number(searchParams.get('page')) || 1,
limit: Number(searchParams.get('limit')) || 20
};
}
Custom Hook for Typed Parameters
// hooks/useTypedSearchParams.ts
import { useSearchParams } from 'react-router-dom';
type ParamValue = string | number | boolean | string[];
interface ParamConfig {
type: 'string' | 'number' | 'boolean' | 'array';
default?: ParamValue;
}
type ParamConfigMap = Record<string, ParamConfig>;
type ParsedParams<T extends ParamConfigMap> = {
[K in keyof T]: T[K]['default'] extends infer D
? D extends undefined
? T[K]['type'] extends 'string'
? string | undefined
: T[K]['type'] extends 'number'
? number | undefined
: T[K]['type'] extends 'boolean'
? boolean | undefined
: string[]
: NonNullable<D>
: never;
};
export function useTypedSearchParams<T extends ParamConfigMap>(
config: T
): [ParsedParams<T>, (updates: Partial<ParsedParams<T>>) => void] {
const [searchParams, setSearchParams] = useSearchParams();
// Parse parameters based on config
const parsed = Object.entries(config).reduce((acc, [key, cfg]) => {
const rawValue = cfg.type === 'array'
? searchParams.getAll(key)
: searchParams.get(key);
let value: ParamValue | undefined;
if (rawValue === null || (Array.isArray(rawValue) && rawValue.length === 0)) {
value = cfg.default;
} else if (cfg.type === 'number') {
const num = Number(rawValue);
value = isNaN(num) ? cfg.default : num;
} else if (cfg.type === 'boolean') {
value = rawValue === 'true';
} else {
value = rawValue as string | string[];
}
return { ...acc, [key]: value };
}, {} as ParsedParams<T>);
// Update parameters
const updateParams = (updates: Partial<ParsedParams<T>>) => {
const newParams = new URLSearchParams(searchParams);
Object.entries(updates).forEach(([key, value]) => {
if (value === undefined || value === null) {
newParams.delete(key);
} else if (Array.isArray(value)) {
newParams.delete(key);
value.forEach(v => newParams.append(key, String(v)));
} else {
newParams.set(key, String(value));
}
});
setSearchParams(newParams, { replace: true });
};
return [parsed, updateParams];
}
// Usage
function ProductList() {
const [filters, setFilters] = useTypedSearchParams({
category: { type: 'string' },
brand: { type: 'array', default: [] },
minPrice: { type: 'number' },
maxPrice: { type: 'number' },
inStock: { type: 'boolean', default: false },
page: { type: 'number', default: 1 },
limit: { type: 'number', default: 20 }
});
// filters is fully typed!
const { category, brand, minPrice, page } = filters;
// Update with type safety
setFilters({ category: 'electronics', page: 1 });
return <div>Product List</div>;
}
Zod Schema Validation
Use Zod for runtime validation of URL parameters:
import { z } from 'zod';
import { useSearchParams } from 'react-router-dom';
// Define schema
const ProductFilterSchema = z.object({
category: z.string().optional(),
brand: z.array(z.string()).optional(),
minPrice: z.coerce.number().min(0).optional(),
maxPrice: z.coerce.number().min(0).optional(),
inStock: z
.enum(['true', 'false'])
.transform(val => val === 'true')
.optional(),
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(20)
});
type ProductFilters = z.infer<typeof ProductFilterSchema>;
function useValidatedFilters(): ProductFilters {
const [searchParams] = useSearchParams();
const rawParams = {
category: searchParams.get('category') ?? undefined,
brand: searchParams.getAll('brand'),
minPrice: searchParams.get('minPrice') ?? undefined,
maxPrice: searchParams.get('maxPrice') ?? undefined,
inStock: searchParams.get('inStock') ?? undefined,
page: searchParams.get('page') ?? undefined,
limit: searchParams.get('limit') ?? undefined
};
// Validate and parse
const result = ProductFilterSchema.safeParse(rawParams);
if (!result.success) {
console.error('Invalid URL parameters:', result.error);
// Return defaults on validation failure
return ProductFilterSchema.parse({});
}
return result.data;
}
💡 Type Safety Tips
- Define interfaces - Create types for your parameter shapes
- Validate at runtime - URL params can be manipulated by users
- Provide defaults - Handle missing or invalid parameters
- Parse carefully - Convert strings to numbers/booleans safely
- Use Zod or similar - Runtime validation with type inference
🎨 Advanced URL State Patterns
Let's explore sophisticated patterns for managing complex application state through URL parameters.
Sorting with URL Parameters
Implement sortable columns that sync with the URL:
// components/SortableTable.tsx
import { useSearchParams } from 'react-router-dom';
type SortDirection = 'asc' | 'desc';
interface Column {
key: string;
label: string;
sortable?: boolean;
}
interface SortableTableProps {
columns: Column[];
data: any[];
}
export function SortableTable({ columns, data }: SortableTableProps) {
const [searchParams, setSearchParams] = useSearchParams();
const sortBy = searchParams.get('sort') || '';
const sortOrder = (searchParams.get('order') || 'asc') as SortDirection;
const handleSort = (columnKey: string) => {
const newParams = new URLSearchParams(searchParams);
// Toggle order if clicking same column
if (sortBy === columnKey) {
const newOrder = sortOrder === 'asc' ? 'desc' : 'asc';
newParams.set('order', newOrder);
} else {
// New column, default to ascending
newParams.set('sort', columnKey);
newParams.set('order', 'asc');
}
setSearchParams(newParams, { replace: true });
};
// Sort data based on URL params
const sortedData = [...data].sort((a, b) => {
if (!sortBy) return 0;
const aValue = a[sortBy];
const bValue = b[sortBy];
if (aValue < bValue) return sortOrder === 'asc' ? -1 : 1;
if (aValue > bValue) return sortOrder === 'asc' ? 1 : -1;
return 0;
});
return (
<table>
<thead>
<tr>
{columns.map(column => (
<th key={column.key}>
{column.sortable ? (
<button
onClick={() => handleSort(column.key)}
className={sortBy === column.key ? 'active' : ''}
>
{column.label}
{sortBy === column.key && (
<span>{sortOrder === 'asc' ? ' ↑' : ' ↓'}</span>
)}
</button>
) : (
column.label
)}
</th>
))}
</tr>
</thead>
<tbody>
{sortedData.map((row, index) => (
<tr key={index}>
{columns.map(column => (
<td key={column.key}>{row[column.key]}</td>
))}
</tr>
))}
</tbody>
</table>
);
}
Tab Navigation with URL State
Keep active tab in the URL for bookmarkable tab states:
// components/TabbedInterface.tsx
import { useSearchParams } from 'react-router-dom';
interface Tab {
id: string;
label: string;
content: React.ReactNode;
}
interface TabbedInterfaceProps {
tabs: Tab[];
defaultTab?: string;
}
export function TabbedInterface({ tabs, defaultTab }: TabbedInterfaceProps) {
const [searchParams, setSearchParams] = useSearchParams();
const activeTab = searchParams.get('tab') || defaultTab || tabs[0].id;
const setActiveTab = (tabId: string) => {
const newParams = new URLSearchParams(searchParams);
if (tabId === defaultTab || tabId === tabs[0].id) {
newParams.delete('tab');
} else {
newParams.set('tab', tabId);
}
setSearchParams(newParams, { replace: true });
};
const activeTabContent = tabs.find(tab => tab.id === activeTab)?.content;
return (
<div className="tabbed-interface">
<div className="tab-buttons" role="tablist">
{tabs.map(tab => (
<button
key={tab.id}
role="tab"
aria-selected={activeTab === tab.id}
aria-controls={`panel-${tab.id}`}
onClick={() => setActiveTab(tab.id)}
className={activeTab === tab.id ? 'active' : ''}
>
{tab.label}
</button>
))}
</div>
<div
role="tabpanel"
id={`panel-${activeTab}`}
className="tab-content"
>
{activeTabContent}
</div>
</div>
);
}
// Usage
function UserProfile() {
const tabs = [
{ id: 'overview', label: 'Overview', content: <Overview /> },
{ id: 'posts', label: 'Posts', content: <Posts /> },
{ id: 'settings', label: 'Settings', content: <Settings /> }
];
return <TabbedInterface tabs={tabs} defaultTab="overview" />;
}
View Mode Toggle
Switch between different view modes (grid/list) using URL state:
// components/ViewModeToggle.tsx
import { useSearchParams } from 'react-router-dom';
type ViewMode = 'grid' | 'list';
export function ViewModeToggle() {
const [searchParams, setSearchParams] = useSearchParams();
const view = (searchParams.get('view') || 'grid') as ViewMode;
const setViewMode = (mode: ViewMode) => {
const newParams = new URLSearchParams(searchParams);
if (mode === 'grid') {
newParams.delete('view'); // Default, no need to store
} else {
newParams.set('view', mode);
}
setSearchParams(newParams, { replace: true });
};
return (
<div className="view-mode-toggle">
<button
onClick={() => setViewMode('grid')}
className={view === 'grid' ? 'active' : ''}
aria-label="Grid view"
>
⊞ Grid
</button>
<button
onClick={() => setViewMode('list')}
className={view === 'list' ? 'active' : ''}
aria-label="List view"
>
☰ List
</button>
</div>
);
}
// Using the view mode
function ProductGrid() {
const [searchParams] = useSearchParams();
const view = searchParams.get('view') || 'grid';
return (
<div className={`products-${view}`}>
{/* Products rendered based on view mode */}
</div>
);
}
Date Range Picker with URL State
// components/DateRangePicker.tsx
import { useSearchParams } from 'react-router-dom';
import { useState } from 'react';
export function DateRangePicker() {
const [searchParams, setSearchParams] = useSearchParams();
const [startDate, setStartDate] = useState(
searchParams.get('startDate') || ''
);
const [endDate, setEndDate] = useState(
searchParams.get('endDate') || ''
);
const applyDateRange = () => {
const newParams = new URLSearchParams(searchParams);
if (startDate) {
newParams.set('startDate', startDate);
} else {
newParams.delete('startDate');
}
if (endDate) {
newParams.set('endDate', endDate);
} else {
newParams.delete('endDate');
}
newParams.set('page', '1');
setSearchParams(newParams, { replace: true });
};
const clearDates = () => {
const newParams = new URLSearchParams(searchParams);
newParams.delete('startDate');
newParams.delete('endDate');
setSearchParams(newParams, { replace: true });
setStartDate('');
setEndDate('');
};
return (
<div className="date-range-picker">
<label>
Start Date:
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
</label>
<label>
End Date:
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</label>
<button onClick={applyDateRange}>Apply</button>
<button onClick={clearDates}>Clear</button>
</div>
);
}
Syncing State Between URL and Component
Handle complex state synchronization patterns:
// hooks/useSyncedState.ts
import { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
export function useSyncedState<T>(
paramKey: string,
defaultValue: T,
serialize: (value: T) => string,
deserialize: (value: string) => T
): [T, (value: T) => void] {
const [searchParams, setSearchParams] = useSearchParams();
// Initialize from URL or default
const [state, setState] = useState<T>(() => {
const urlValue = searchParams.get(paramKey);
return urlValue ? deserialize(urlValue) : defaultValue;
});
// Sync URL changes back to state
useEffect(() => {
const urlValue = searchParams.get(paramKey);
if (urlValue) {
const deserialized = deserialize(urlValue);
setState(deserialized);
}
}, [searchParams, paramKey]);
// Update both state and URL
const updateState = (newValue: T) => {
setState(newValue);
const newParams = new URLSearchParams(searchParams);
const serialized = serialize(newValue);
if (serialized === serialize(defaultValue)) {
newParams.delete(paramKey);
} else {
newParams.set(paramKey, serialized);
}
setSearchParams(newParams, { replace: true });
};
return [state, updateState];
}
// Usage
function FilteredList() {
// Sync array of selected items
const [selectedItems, setSelectedItems] = useSyncedState<string[]>(
'items',
[],
(items) => items.join(','),
(str) => str ? str.split(',') : []
);
// Sync complex object
const [filters, setFilters] = useSyncedState<{min: number; max: number}>(
'priceRange',
{ min: 0, max: 1000 },
(obj) => JSON.stringify(obj),
(str) => JSON.parse(str)
);
return <div>Filtered List</div>;
}
Complete Product Listing Example
Putting it all together - a complete product listing with search, filters, sorting, and pagination:
// pages/ProductListing.tsx
import { useSearchParams } from 'react-router-dom';
import { useState, useEffect } from 'react';
interface Product {
id: number;
name: string;
category: string;
brand: string;
price: number;
inStock: boolean;
}
export function ProductListing() {
const [searchParams, setSearchParams] = useSearchParams();
const [products, setProducts] = useState<Product[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [isLoading, setIsLoading] = useState(false);
// Extract all parameters
const filters = {
q: searchParams.get('q') || '',
category: searchParams.get('category') || '',
brand: searchParams.getAll('brand'),
minPrice: Number(searchParams.get('minPrice')) || undefined,
maxPrice: Number(searchParams.get('maxPrice')) || undefined,
inStock: searchParams.get('inStock') === 'true',
sort: searchParams.get('sort') || 'name',
order: searchParams.get('order') || 'asc',
page: Number(searchParams.get('page')) || 1,
limit: Number(searchParams.get('limit')) || 20,
view: searchParams.get('view') || 'grid'
};
// Fetch products when params change
useEffect(() => {
const fetchProducts = async () => {
setIsLoading(true);
try {
const queryString = searchParams.toString();
const response = await fetch(`/api/products?${queryString}`);
const data = await response.json();
setProducts(data.products);
setTotalCount(data.totalCount);
} catch (error) {
console.error('Failed to fetch products:', error);
} finally {
setIsLoading(false);
}
};
fetchProducts();
}, [searchParams]);
const totalPages = Math.ceil(totalCount / filters.limit);
return (
<div className="product-listing">
{/* Search Bar */}
<SearchBar />
<div className="listing-layout">
{/* Sidebar with filters */}
<aside className="filters">
<FilterPanel />
</aside>
{/* Main content */}
<main>
{/* Toolbar */}
<div className="toolbar">
<div className="results-info">
{totalCount} products found
</div>
<div className="toolbar-controls">
<SortDropdown />
<ViewModeToggle />
<ItemsPerPage />
</div>
</div>
{/* Active filters */}
<ActiveFilters />
{/* Loading state */}
{isLoading && <LoadingSpinner />}
{/* Products */}
{!isLoading && (
<div className={`products-${filters.view}`}>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
)}
{/* No results */}
{!isLoading && products.length === 0 && (
<div className="no-results">
<p>No products found</p>
<button onClick={() => setSearchParams({})}>
Clear all filters
</button>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<Pagination
currentPage={filters.page}
totalPages={totalPages}
/>
)}
</main>
</div>
</div>
);
}
✅ Advanced Pattern Best Practices
- Keep URLs clean - Use defaults, remove params that match defaults
- Handle all edge cases - Invalid values, missing params, etc.
- Sync carefully - Avoid infinite loops when syncing URL and state
- Test sharing - Verify URLs work when shared or bookmarked
- Consider SEO - Use meaningful parameter names
- Document behavior - Make it clear what params do
🏋️ Exercise: Complete Search & Filter System
Build a full-featured product listing page with all URL state features.
Requirements:
- Search input with debouncing
- Category filter (single select dropdown)
- Price range filter (min/max inputs)
- Sort by name or price (with asc/desc)
- Pagination (with items per page selector)
- View mode toggle (grid/list)
- Active filters display with remove buttons
- All state synced to URL parameters
- Proper TypeScript typing throughout
💡 Hint
Break it down into components and reuse the patterns we've learned:
- SearchBar component with useDebounce
- Filter components that update URLSearchParams
- Main page component that reads all params and fetches data
🎯 Summary and Next Steps
What You've Learned
In this lesson, you've mastered URL query parameters and state management:
📚 Key Concepts Covered
- URL Query Parameters - Understanding structure and benefits
- useSearchParams Hook - Reading and writing URL parameters
- Search Implementation - Building search with debouncing and suggestions
- Filter Systems - Single, multi, range, and boolean filters
- Pagination - Page navigation and items per page
- TypeScript Typing - Type-safe parameter handling
- Advanced Patterns - Sorting, tabs, view modes, date ranges
- State Synchronization - Keeping URL and component state in sync
Quick Reference
| Feature | URL Pattern | Use Case |
|---|---|---|
| Search | ?q=laptop | User search queries |
| Single Filter | ?category=electronics | Dropdown selections |
| Multi Filter | ?brand=apple&brand=samsung | Checkbox selections |
| Range | ?minPrice=100&maxPrice=500 | Price, date ranges |
| Pagination | ?page=2&limit=20 | Paged results |
| Sorting | ?sort=price&order=desc | Sortable lists |
Common Patterns Cheat Sheet
// Read a parameter
const query = searchParams.get('q') || '';
// Set a parameter
const newParams = new URLSearchParams(searchParams);
newParams.set('category', 'electronics');
setSearchParams(newParams, { replace: true });
// Delete a parameter
newParams.delete('category');
// Multiple values
const brands = searchParams.getAll('brand');
// Set multiple values
brands.forEach(b => newParams.append('brand', b));
// Type-safe reading
const page = Number(searchParams.get('page')) || 1;
const inStock = searchParams.get('inStock') === 'true';
Best Practices Checklist
✅ URL Parameter Checklist
- ✅ Use meaningful parameter names (q, category, not x, y)
- ✅ Validate and sanitize all URL parameters
- ✅ Provide sensible defaults for missing params
- ✅ Use replace: true for filters to avoid history clutter
- ✅ Reset pagination when filters change
- ✅ Make URLs shareable and bookmarkable
- ✅ Remove default values from URL to keep it clean
- ✅ Type parameters properly with TypeScript
- ✅ Debounce rapid updates (search as you type)
- ✅ Test URLs work when shared or refreshed
Performance Tips
⚡ Optimization Strategies
- Debounce search input - Avoid excessive API calls
- Use replace navigation - Don't create history entries for filters
- Memoize URL parsing - Cache expensive parameter parsing
- Batch parameter updates - Update multiple params at once
- Lazy load filter options - Don't load all brands upfront
- Consider URL length - Very long URLs may cause issues
Next Lesson Preview
🔜 Coming Up: Lesson 6.5 - Layout Routes
In the next lesson, you'll learn:
- Creating shared layouts with Outlet
- Nested route structures
- Persistent navigation and headers
- Layout composition patterns
- Multi-layout applications
- Building professional app structures
Practice Projects
-
Job Board with Filters
- Search by job title and keywords
- Filter by location, salary range, job type
- Sort by date posted, salary, relevance
- Pagination with adjustable results per page
-
Movie Database Browser
- Search movies by title
- Filter by genre, year, rating
- Sort by popularity, rating, release date
- Grid/list view toggle
-
Real Estate Listings
- Search by location
- Filter by price, bedrooms, property type
- Map/list view modes
- Save searches with shareable URLs
Additional Resources
- React Router: useSearchParams
- MDN: URLSearchParams API
- Web.dev: URLPattern API
- Zod: TypeScript Schema Validation
🎉 Congratulations!
You've completed Lesson 6.4 and now have comprehensive knowledge of URL state management. You can:
- ✅ Build powerful search functionality
- ✅ Implement complex filter systems
- ✅ Create paginated result lists
- ✅ Handle sorting and view modes
- ✅ Type URL parameters safely with TypeScript
- ✅ Build shareable and bookmarkable application states
Ready to build professional layouts? Let's continue to the final routing lesson!