Filtering Sorting: UI for Data Lists
Learn: Filtering Sorting: UI for Data Lists
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
- URL as Source of Truth: Query strings store all filter/sort state
- Bidirectional Sync: UI โ Query String โ Data State
- Debounced Updates: Prevent excessive URL rewrites
- Type-Safe Parsing: Validate and sanitize query parameters
- 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