Skip to main content

Command Palette

Search for a command to run...

Filtering Sorting: UI for Data Lists

Learn: Filtering Sorting: UI for Data Lists

Updated
โ€ข7 min readโ€ขView as Markdown
T

Welcome to TopperBlog! ๐Ÿ‘‹

I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.

๐ŸŽฏ What I Write About: โ€ข AI/ML Engineering & LLMs โ€ข Web3 & Blockchain Development
โ€ข System Design & Architecture โ€ข Interview Preparation (FAANG) โ€ข Freelancing & Remote Work โ€ข Modern Tech Stacks (Next.js, React, Rust, TypeScript) โ€ข Performance Optimization & Best Practices

๐Ÿ’ผ Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.

๐Ÿ“š 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.

๐ŸŒ Let's connect and grow together in this amazing tech journey!

#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering

Filtering & Sorting: UI for Data Lists with Query String Management

Problem

Modern web applications need to display large datasets with the ability to:

  • Filter data by multiple criteria
  • Sort by different columns
  • Maintain state across page refreshes
  • Share filtered/sorted views via URLs
  • Provide intuitive UI controls

Without proper query string management, users lose their filtering/sorting state on refresh, can't share specific views, and experience poor UX.


Solution Architecture

Core Principles

  1. URL as Source of Truth: Query strings store all filter/sort state
  2. Bidirectional Sync: UI โ†” Query String โ†” Data State
  3. Debounced Updates: Prevent excessive URL rewrites
  4. Type-Safe Parsing: Validate and sanitize query parameters
  5. Composable Filters: Build complex queries from simple components

Key Components

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚         Filter/Sort UI Controls         โ”‚
โ”‚  (Dropdowns, Checkboxes, Search Bars)   โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
               โ”‚
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚    Query String Manager (Parser)        โ”‚
โ”‚  (Encode/Decode URL Parameters)         โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
               โ”‚
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚      Data Filter & Sort Engine          โ”‚
โ”‚  (Apply Filters, Sort Results)          โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
               โ”‚
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚         Rendered Data List              โ”‚
โ”‚  (Table, Cards, Grid)                   โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Code Implementation

1. Query String Manager

// queryManager.ts
interface FilterState {
  search?: string;
  category?: string[];
  priceRange?: [number, number];
  sortBy?: string;
  sortOrder?: 'asc' | 'desc';
  page?: number;
}

class QueryStringManager {
  private debounceTimer: NodeJS.Timeout | null = null;
  private debounceDelay = 300;

  // Parse query string to filter state
  parseQueryString(search: string): FilterState {
    const params = new URLSearchParams(search);

    return {
      search: params.get('q') || undefined,
      category: params.getAll('cat') || undefined,
      priceRange: this.parsePriceRange(params.get('price')),
      sortBy: params.get('sort') || 'relevance',
      sortOrder: (params.get('order') as 'asc' | 'desc') || 'desc',
      page: parseInt(params.get('page') || '1'),
    };
  }

  // Convert filter state to query string
  encodeQueryString(state: FilterState): string {
    const params = new URLSearchParams();

    if (state.search) params.set('q', state.search);
    if (state.category?.length) {
      state.category.forEach(cat => params.append('cat', cat));
    }
    if (state.priceRange) {
      params.set('price', `${state.priceRange[0]}-${state.priceRange[1]}`);
    }
    if (state.sortBy) params.set('sort', state.sortBy);
    if (state.sortOrder) params.set('order', state.sortOrder);
    if (state.page && state.page > 1) params.set('page', state.page.toString());

    return params.toString();
  }

  private parsePriceRange(priceStr: string | null): [number, number] | undefined {
    if (!priceStr) return undefined;
    const [min, max] = priceStr.split('-').map(Number);
    return [min, max];
  }

  // Update URL without page reload
  updateURL(state: FilterState): void {
    if (this.debounceTimer) clearTimeout(this.debounceTimer);

    this.debounceTimer = setTimeout(() => {
      const queryString = this.encodeQueryString(state);
      const newURL = `${window.location.pathname}?${queryString}`;
      window.history.replaceState({ ...state }, '', newURL);
    }, this.debounceDelay);
  }

  // Get current state from URL
  getCurrentState(): FilterState {
    return this.parseQueryString(window.location.search);
  }
}

export const queryManager = new QueryStringManager();

2. React Component with Hooks

// useFilteredData.ts
import { useState, useEffect, useCallback } from 'react';
import { queryManager } from './queryManager';

interface DataItem {
  id: string;
  name: string;
  category: string;
  price: number;
  rating: number;
}

export function useFilteredData(allData: DataItem[]) {
  const [filterState, setFilterState] = useState<FilterState>(() =>
    queryManager.getCurrentState()
  );
  const [filteredData, setFilteredData] = useState<DataItem[]>(allData);

  // Apply filters and sorting
  const applyFilters = useCallback((state: FilterState, data: DataItem[]) => {
    let result = [...data];

    // Search filter
    if (state.search) {
      const query = state.search.toLowerCase();
      result = result.filter(item =>
        item.name.toLowerCase().includes(query)
      );
    }

    // Category filter
    if (state.category?.length) {
      result = result.filter(item =>
        state.category!.includes(item.category)
      );
    }

    // Price range filter
    if (state.priceRange) {
      const [min, max] = state.priceRange;
      result = result.filter(item =>
        item.price >= min && item.price <= max
      );
    }

    // Sorting
    result.sort((a, b) => {
      let aVal: any = a[state.sortBy as keyof DataItem] || 0;
      let bVal: any = b[state.sortBy as keyof DataItem] || 0;

      const comparison = aVal > bVal ? 1 : aVal < bVal ? -1 : 0;
      return state.sortOrder === 'asc' ? comparison : -comparison;
    });

    // Pagination
    const pageSize = 20;
    const startIdx = ((state.page || 1) - 1) * pageSize;
    return result.slice(startIdx, startIdx + pageSize);
  }, []);

  // Update filtered data when state changes
  useEffect(() => {
    setFilteredData(applyFilters(filterState, allData));
  }, [filterState, allData, applyFilters]);

  // Update URL when state changes
  useEffect(() => {
    queryManager.updateURL(filterState);
  }, [filterState]);

  // Handle filter changes
  const updateFilter = useCallback((updates: Partial<FilterState>) => {
    setFilterState(prev => ({
      ...prev,
      ...updates,
      page: 1, // Reset to first page on filter change
    }));
  }, []);

  return { filterState, filteredData, updateFilter };
}

3. UI Component

// DataListWithFilters.tsx
import React from 'react';
import { useFilteredData } from './useFilteredData';

interface Props {
  data: DataItem[];
  categories: string[];
}

export function DataListWithFilters({ data, categories }: Props) {
  const { filterState, filteredData, updateFilter } = useFilteredData(data);

  return (
    <div className="data-list-container">
      {/* Filter Controls */}
      <aside className="filters-panel">
        <h3>Filters</h3>

        {/* Search */}
        <div className="filter-group">
          <label>Search</label>
          <input
            type="text"
            placeholder="Search products..."
            value={filterState.search || ''}
            onChange={(e) => updateFilter({ search: e.target.value })}
            className="search-input"
          />
        </div>

        {/* Category Checkboxes */}
        <div className="filter-group">
          <label>Category</label>
          {categories.map(cat => (
            <label key={cat} className="checkbox-label">
              <input
                type="checkbox"
                checked={filterState.category?.includes(cat) || false}
                onChange={(e) => {
                  const newCats = e.target.checked
                    ? [...(filterState.category || []), cat]
                    : filterState.category?.filter(c => c !== cat) || [];
                  updateFilter({ category: newCats });
                }}
              />
              {cat}
            </label>
          ))}
        </div>

        {/* Price Range Slider */}
        <div className="filter-group">
          <label>Price Range: ${filterState.priceRange?.[0] || 0} - ${filterState.priceRange?.[1] || 1000}</label>
          <input
            type="range"
            min="0"
            max="1000"
            value={filterState.priceRange?.[0] || 0}
            onChange={(e) => {
              const min = parseInt(e.target.value);
              const max = filterState.priceRange?.[1] || 1000;
              updateFilter({ priceRange: [min, max] });
            }}
          />
          <input
            type="range"
            min="0"
            max="1000"
            value={filterState.priceRange?.[1] || 1000}
            onChange={(e) => {
              const max = parseInt(e.target.value);
              const min = filterState.priceRange?.[0] || 0;
              updateFilter({ priceRange: [min, max] });
            }}
          />
        </div>

        {/* Clear Filters */}
        <button
          onClick={() => updateFilter({
            search: undefined,
            category: undefined,
            priceRange: undefined,
          })}
          className="btn-clear"
        >
          Clear Filters
        </button>
      </aside>

      {/* Main Content */}
      <main className="data-list-main">
        {/* Sort Controls */}
        <div className="sort-controls">
          <select
            value={filterState.sortBy || 'relevance'}
            onChange={(e) => updateFilter({ sortBy: e.target.value })}
          >
            <option value="relevance">Relevance</option>
            <option value="price">Price</option>
            <option value="rating">Rating</option>
            <option value="name">Name</option>
          </select>

          <button
            onClick={() => updateFilter({
              sortOrder: filterState.sortOrder === 'asc' ? 'desc' : 'asc'
            })}
            className="btn-sort-order"
          >
            {filterState.sortOrder === 'asc' ? 'โ†‘ Ascending' : 'โ†“ Descending'}
          </button>

          <span className="result-count">
            {filteredData.length} results
          </span>
        </div>

        {/* Data List */}
        <div className="data-grid">
          {filteredData.length > 0 ? (
            filteredData.map(item => (
              <div key={item.id} className="data-card">
                <h4>{item.name}</h4>
                <p className="category">{item.category}</p>
                <p className="price">${item.price}</p>
                <p className="rating">โญ {item.rating}/5</p>
              </div>
            ))
          ) : (
            <p className="no-results">No items match your filters</p>
          )}
        </div>
      </main>
    </div>
  );
}

4. CSS Styling

/* styles.css */
.data-list-container {
  display: grid;
  grid-template-columns: 250px 1fr;
  gap: 2rem;
  padding: 2rem;
  max-width: 1400px;
  margin: 0 auto;
}

.filters-panel {
  background: #f5f5f5;
  padding: 1.5rem;
  border-radius: 8px;
  height: fit-content;
  position: sticky;
  top: 2rem;
}

.filter-group {
  margin-bottom: 1.5rem;
}

.filter-group label {
  display: block;
  font-weight: 600;
  margin-bottom: 0.5rem;
  color: #333;
}

.search-input,
.filter-group input[type="range"] {
  width: 100%;
  padding: 0.5rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 0.9rem;
}

.checkbox-label {
  display: flex;
  align-items: center;
  margin: 0.5rem 0;
  cursor: pointer;
}

.checkbox-label input {
  margin-right: 0.5rem;
}

.btn-clear {
  width: 100%;
  padding: 0.75rem;
  background: #ff6b6b;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-weight: 600;
  transition: background 0.2s;
}

.btn-clear:hover {
  background: #ff5252;
}

.sort-controls {
  display: flex;
  gap: 1rem;
  margin-bottom: 2rem;
  align-items: center;
}

.sort-controls select,
.btn-sort-order {
  padding: 0.5rem 1rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  background: white;
  cursor: pointer;
  font-size: 0.9rem;
}

.result-count {
  margin-left: auto;
  color: #666;
  font-size: 0.9rem;
}

.data-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
  gap: 1.5rem;
}

.data-card {
  background: white;
  border: 1px solid #eee;
  border-radius: 8px;
  padding: 1.5rem;
  transition: box-shadow 0.2s;
}

.data-card:hover {
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}

.data-card h4 {
  margin: 0 0 0.5rem 0;
  color: #333;
}

.category {
  color: #999;
  font-size: 0.85rem;
  margin: 0.25rem 0;
}

.price {
  font-size: 1.25rem;
  font-weight: 700;
  color: #2ecc71;
  margin: 0.5rem 0;
}

.rating {
  color: #f39c12;
  margin: 0;
}

.no-results {
  grid-column: 1 / -1;
  text-align: center;
  color: #999;
  padding: 2rem;
}

@media (max-width: 768px) {
  .data-list-container {
    grid-template-columns: 1fr;
  }

  .filters-panel {
    position: static;
  }

  .data-grid {
    grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  }
}

Tips & Best Practices

1. URL Encoding Best Practices

// โœ… DO: Use URLSearchParams for proper encoding
const params = new URLSearchParams();
params.set('search', 'laptop & phone'); // Handles special chars

// โŒ DON'T: Manual string concatenation
const url = `?search=${userInput}`; // Breaks with special chars

2. Debounce Search Input

// Prevents excessive URL updates while typing
const [searchTimeout, setSearchTimeout] = useState<NodeJS.Timeout>();

const handleSearch = (value: string) => {
  clearTimeout(searchTimeout);
  setSearchTimeout(
    setTimeout(() => updateFilter({ search: value }), 300)
  );
};

3. Preserve State on Navigation

// Use window.history.replaceState, not pushState
// This prevents back button spam
window.history.replaceState({ filterState }, '', newURL);

4. Validate Query Parameters

// Sanitize and validate all query inputs
const validateCategory = (cat: string): boolean => {
  const validCategories = ['electronics', 'clothing', 'books'];
  return validCategories.includes(cat);
};

5. Performance Optimization

// Memoize expensive filter operations
const filteredData = useMemo(() => {
  return applyFilters(filterState, allData);
}, [filterState, allData]);

// Virtualize long lists
import { FixedSizeList } from 'react-window';

6. Accessibility

// Add ARIA labels and keyboard navigation
<input
  aria-label="Search products"
  onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>

7. Share Filter URLs

// Generate shareable links
const shareURL = `${window.location.origin}${window.location.pathname}?${queryString}`;
navigator.clipboard.writeText(shareURL);

8. Handle Empty States

// Provide helpful messaging
{filteredData.length === 0 && (
  <div className="empty-state">
    <p>No results found</p>
    <button onClick={() => updateFilter({ search: undefined })}>
      Clear search
    </button>
  </div>
)}

Summary

This implementation provides:

  • โœ… Persistent filter state via URL query strings
  • โœ… Shareable filtered views
  • โœ… Smooth UX with debouncing
  • โœ… Type-safe parameter handling
  • โœ… Responsive, accessible UI
  • โœ… Scalable architecture for complex filters

More from this blog