Skip to main content

🔍 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

flowchart LR A[Application State] --> B{Should it be in URL?} B -->|Yes| C[URL Parameters] B -->|No| D[Component State] C --> E[Shareable] C --> F[Bookmarkable] C --> G[SEO Indexed] D --> H[Private] D --> I[Temporary] D --> J[Not Shareable] style C fill:#48bb78,color:#fff style D fill:#667eea,color:#fff

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 exists
  • set(key, value) - Set a parameter
  • delete(key) - Remove a parameter
  • toString() - 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:

  1. Create a search input that updates the q parameter
  2. Display the current search query from the URL
  3. Add a "Clear Search" button that removes the q parameter
  4. Use replace navigation for the search updates
  5. 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.

flowchart TD A[User Selects Filter] --> B[Update URL Params] B --> C[URL Changes] C --> D[Component Re-renders] D --> E[Read New Params] E --> F[Fetch Filtered Data] F --> G[Display Results] style A fill:#667eea,color:#fff style G fill:#48bb78,color:#fff

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:

  1. Add a category dropdown filter (single select)
  2. Add a price range filter (min and max inputs)
  3. Add an "In Stock" checkbox filter
  4. Display active filters with ability to remove them
  5. Show product count based on applied filters
  6. 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.

flowchart LR A[Page 1] -->|?page=2| B[Page 2] B -->|?page=3| C[Page 3] C -->|?page=2| B B -->|?page=1| A style B fill:#667eea,color:#fff

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:

  1. Search input with debouncing
  2. Category filter (single select dropdown)
  3. Price range filter (min/max inputs)
  4. Sort by name or price (with asc/desc)
  5. Pagination (with items per page selector)
  6. View mode toggle (grid/list)
  7. Active filters display with remove buttons
  8. All state synced to URL parameters
  9. 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

  1. 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
  2. Movie Database Browser
    • Search movies by title
    • Filter by genre, year, rating
    • Sort by popularity, rating, release date
    • Grid/list view toggle
  3. Real Estate Listings
    • Search by location
    • Filter by price, bedrooms, property type
    • Map/list view modes
    • Save searches with shareable URLs

Additional Resources

🎉 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!