Product Filter Sidebar in React: Price Range, Multi-Select, URL State
Build a production-ready product filter sidebar in React with price range sliders, multi-select checkboxes, and shareable URL state — no library required.
Why Most Filter Sidebars Ship Broken
You've seen it a hundred times. A slick-looking filter sidebar with price sliders and category checkboxes — and the moment a user refreshes the page, every selection evaporates. Or worse, the filters work fine until someone hits the back button and lands on a completely reset page with no context. That's not a UI bug, it's an architecture bug.
Honestly, the root cause is almost always state management done too locally. Developers reach for useState for each filter group, wire up an onChange, and call it done. The filters work — in memory, for one tab, for one session. In a real ecommerce context that's not enough. Users share filtered URLs with colleagues. They bookmark searches. They expect the browser back button to behave sanely.
This guide builds a complete product filter sidebar from scratch: price range with a dual-thumb slider, multi-select checkboxes for categories and brands, and full URL state sync using React 18's useSearchParams hook (or a Next.js 15 equivalent). No filter libraries, no redux, nothing you'd need to justify to your team. Just URLSearchParams, controlled components, and a bit of discipline.
Data Model and Filter Shape
Before writing a single component, you need to agree on the shape of your filter state. Keep it flat. Nested filter objects look elegant on a whiteboard but turn into a serialization nightmare the moment you try to put them in a URL.
// types/filters.ts
export interface ProductFilters {
priceMin: number;
priceMax: number;
categories: string[]; // e.g. ['sneakers', 'boots']
brands: string[]; // e.g. ['Nike', 'Adidas']
inStock: boolean;
}
export const DEFAULT_FILTERS: ProductFilters = {
priceMin: 0,
priceMax: 500,
categories: [],
brands: [],
inStock: false,
};The categories and brands arrays map cleanly to repeated query params: ?categories=sneakers&categories=boots. That's standard query string behaviour — URLSearchParams.getAll('categories') returns the array directly. Worth noting: keep your param names short and lowercase. You're going to read and write these constantly during development, and categories beats selectedCategoryFilters every time.
One more thing — define a filtersToParams and a paramsToFilters utility up front. Centralising the serialization logic here means you change it in one place if the shape ever evolves, not scattered across three hooks.
URL State with useSearchParams
React Router 6.4+ and Next.js 13+ both expose a useSearchParams hook. The patterns are nearly identical, so I'll show the React Router version and call out the Next.js difference at the end.
// hooks/useFilterState.ts
import { useSearchParams } from 'react-router-dom';
import { ProductFilters, DEFAULT_FILTERS } from '../types/filters';
export function useFilterState() {
const [searchParams, setSearchParams] = useSearchParams();
const filters: ProductFilters = {
priceMin: Number(searchParams.get('priceMin') ?? DEFAULT_FILTERS.priceMin),
priceMax: Number(searchParams.get('priceMax') ?? DEFAULT_FILTERS.priceMax),
categories: searchParams.getAll('categories'),
brands: searchParams.getAll('brands'),
inStock: searchParams.get('inStock') === 'true',
};
function setFilters(next: Partial<ProductFilters>) {
setSearchParams((prev) => {
const params = new URLSearchParams(prev);
const merged = { ...filters, ...next };
// scalar values
params.set('priceMin', String(merged.priceMin));
params.set('priceMax', String(merged.priceMax));
merged.inStock
? params.set('inStock', 'true')
: params.delete('inStock');
// array values — delete all then re-add
params.delete('categories');
merged.categories.forEach((c) => params.append('categories', c));
params.delete('brands');
merged.brands.forEach((b) => params.append('brands', b));
return params;
});
}
return { filters, setFilters };
}In practice, the pattern of delete-then-append for array params trips people up the first time. URLSearchParams doesn't have a setAll method, so you manually clear the key before appending multiple values. Get this wrong and you'll stack duplicate params on every render.
For Next.js 15, swap the import to import { useSearchParams, useRouter, usePathname } from 'next/navigation' and call router.push(pathname + '?' + params.toString()) instead of setSearchParams. The logic inside setFilters is identical. Quick aside: in Next.js App Router, useSearchParams must be used inside a Suspense boundary — wrap your filter sidebar accordingly or you'll get a build error.
The payoff for this plumbing is immediate. Refreshing the page preserves every filter. Sharing the URL works. The browser back button steps through filter history. Users expect all of that — it's not a bonus, it's table stakes for any ecommerce filter UI built in 2026.
The Price Range Slider
Dual-thumb range sliders are the one place where a native HTML <input type="range"> genuinely falls short. A single <input> maps to one value. To get two thumbs you need either a library or a custom implementation with two overlapping range inputs — the latter is what we'll use, because the result is 40 lines of CSS and zero dependencies.
// components/PriceRange.tsx
interface PriceRangeProps {
min: number;
max: number;
value: [number, number];
onChange: (value: [number, number]) => void;
}
export function PriceRange({ min, max, value, onChange }: PriceRangeProps) {
const [lo, hi] = value;
return (
<div className="relative h-6">
{/* track */}
<div className="absolute top-2 left-0 right-0 h-1.5 rounded-full bg-zinc-200" />
{/* filled range */}
<div
className="absolute top-2 h-1.5 rounded-full bg-violet-500"
style={{
left: `${((lo - min) / (max - min)) * 100}%`,
right: `${100 - ((hi - min) / (max - min)) * 100}%`,
}}
/>
{/* low thumb */}
<input
type="range"
min={min}
max={max}
value={lo}
onChange={(e) => {
const next = Math.min(Number(e.target.value), hi - 1);
onChange([next, hi]);
}}
className="absolute w-full appearance-none bg-transparent"
style={{ zIndex: lo >= hi - 10 ? 5 : 3 }}
/>
{/* high thumb */}
<input
type="range"
min={min}
max={max}
value={hi}
onChange={(e) => {
const next = Math.max(Number(e.target.value), lo + 1);
onChange([lo, next]);
}}
className="absolute w-full appearance-none bg-transparent"
style={{ zIndex: 4 }}
/>
</div>
);
}The zIndex swap on line 27 is the trick that keeps the lower thumb accessible when both thumbs are pushed to the top end — otherwise the high thumb always intercepts click events and the user can't move the low thumb back down. It's one of those subtle CSS interactions that bites you at 48px of slider travel.
That said, if you want a polished accessible slider out of the box without the zIndex gymnastics, Radix UI's Slider primitive handles everything including keyboard navigation. Install it with npm install @radix-ui/react-slider and style it with Tailwind. Either path works — the custom version is great for learning and for keeping your bundle lean.
Wire the component into your sidebar by pulling values from useFilterState and calling setFilters on change with a debounce to avoid hammering your product query on every drag tick:
``tsx
import { useDebouncedCallback } from 'use-debounce'; // or write your own
const handlePriceChange = useDebouncedCallback(
([priceMin, priceMax]: [number, number]) => setFilters({ priceMin, priceMax }),
300
);
``
Multi-Select Checkboxes for Categories and Brands
Multi-select is the simpler piece, but there are still two gotchas worth avoiding. First: don't call setFilters directly in the onChange handler with a spread of the current array — you'll get stale closure bugs in React's event batching. Always derive the next array from the current URL state, not from a local variable.
// components/CheckboxGroup.tsx
interface CheckboxGroupProps {
label: string;
options: string[];
selected: string[];
onChange: (next: string[]) => void;
}
export function CheckboxGroup({
label,
options,
selected,
onChange,
}: CheckboxGroupProps) {
function toggle(value: string) {
const next = selected.includes(value)
? selected.filter((v) => v !== value)
: [...selected, value];
onChange(next);
}
return (
<fieldset>
<legend className="text-sm font-semibold text-zinc-700 mb-2">{label}</legend>
<ul className="space-y-1.5">
{options.map((opt) => (
<li key={opt}>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={selected.includes(opt)}
onChange={() => toggle(opt)}
className="rounded border-zinc-300 text-violet-500"
/>
<span>{opt}</span>
</label>
</li>
))}
</ul>
</fieldset>
);
}Second gotcha: your options list probably comes from your product data, not a static constant. Derive it at the API layer, not in the component. If you compute unique brands inside a useMemo that depends on the already-filtered product list, you'll end up hiding brand options dynamically as you filter — which confuses users. Look, count the available products per option and grey out the zero-count ones instead of removing them.
Use the CheckboxGroup twice in your sidebar — once for categories, once for brands:
``tsx
<CheckboxGroup
label="Category"
options={ALL_CATEGORIES}
selected={filters.categories}
onChange={(categories) => setFilters({ categories })}
/>
<CheckboxGroup
label="Brand"
options={ALL_BRANDS}
selected={filters.brands}
onChange={(brands) => setFilters({ brands })}
/>
``
The inStock toggle is even simpler — a single checkbox wired to filters.inStock. No special treatment needed beyond making sure params.delete('inStock') runs when it's false, so your URLs stay clean instead of accumulating ?inStock=false garbage.
Putting the Sidebar Together
With the pieces built, the sidebar itself is just composition:
``tsx
// components/FilterSidebar.tsx
import { useFilterState } from '../hooks/useFilterState';
import { PriceRange } from './PriceRange';
import { CheckboxGroup } from './CheckboxGroup';
export function FilterSidebar() {
const { filters, setFilters } = useFilterState();
const hasActiveFilters =
filters.priceMin > 0 ||
filters.priceMax < 500 ||
filters.categories.length > 0 ||
filters.brands.length > 0 ||
filters.inStock;
return (
<aside className="w-64 shrink-0 space-y-6">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-zinc-900">Filters</h2>
{hasActiveFilters && (
<button
onClick={() => setFilters(DEFAULT_FILTERS)}
className="text-xs text-violet-600 hover:underline"
>
Clear all
</button>
)}
</div>
<section>
<h3 className="text-sm font-semibold text-zinc-700 mb-3">Price</h3>
<PriceRange
min={0}
max={500}
value={[filters.priceMin, filters.priceMax]}
onChange={([priceMin, priceMax]) => setFilters({ priceMin, priceMax })}
/>
<div className="flex justify-between text-xs text-zinc-500 mt-1.5">
<span>${filters.priceMin}</span>
<span>${filters.priceMax}</span>
</div>
</section>
<CheckboxGroup
label="Category"
options={ALL_CATEGORIES}
selected={filters.categories}
onChange={(categories) => setFilters({ categories })}
/>
<CheckboxGroup
label="Brand"
options={ALL_BRANDS}
selected={filters.brands}
onChange={(brands) => setFilters({ brands })}
/>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={filters.inStock}
onChange={(e) => setFilters({ inStock: e.target.checked })}
className="rounded border-zinc-300 text-violet-500"
/>
In stock only
</label>
</aside>
);
}
``
The hasActiveFilters derived boolean controlling the "Clear all" button is small but matters a lot. It tells users at a glance that something is active, and gives them a one-click escape hatch. Calling setFilters(DEFAULT_FILTERS) serializes all params back to default and the URL clears itself automatically.
For mobile, you'd typically hide this aside behind a drawer triggered by a "Filter" button. The component logic doesn't change at all — you just swap the layout. Wrap it in a <Sheet> or <Dialog> from Radix and slide it in from the left at viewport widths below 768px.
If you want the filter sidebar to look as sharp as it functions, check out the Empire UI component library. The sidebar shell, checkbox styles, and button components all have pre-built variants that drop right in. You can also pair the whole thing with a glassmorphism treatment using the glassmorphism generator for a more striking visual design in product-forward contexts.
Testing, Edge Cases, and Performance
Testing this kind of component means testing the URL, not internal state. Render your FilterSidebar inside a MemoryRouter (React Router) or mock useSearchParams (Next.js) and assert the query string changes when users interact. Don't test the useState — test the URL string that persists across page loads. That's the real contract.
// FilterSidebar.test.tsx (vitest + testing-library)
it('appends category to URL when checked', async () => {
render(
<MemoryRouter initialEntries={['/']}>
<FilterSidebar />
</MemoryRouter>
);
await userEvent.click(screen.getByLabelText('Sneakers'));
expect(window.location.search).toContain('categories=sneakers');
});A couple of edge cases to handle before shipping. What if priceMax in the URL is lower than priceMin? Validate on read, not on write: const lo = Math.min(rawLo, rawHi - 1). What if categories= contains a value that no longer exists in your product catalogue? The checkbox group won't render it (since it's not in options), but it'll still sit in the URL silently. Clean it up during the paramsToFilters step by filtering against a known-valid set.
Performance-wise: every filter interaction triggers a navigation event (setSearchParams causes a re-render of everything consuming the URL). Wrap your product grid in React.memo and make sure its props are derived from the filter values, not re-created inline on each render. For heavy product grids (100+ items rendered), also consider virtualizing the list with react-virtual. The filter sidebar itself is lightweight — the re-render cost lives in what you're filtering, not the sidebar itself.
Once this is solid, the natural next step is persisting filters across sessions with localStorage as a fallback, or syncing them with a server-side search API that takes the same URL params. Both are additive — the URL-first architecture makes either extension trivial because the URL is already your source of truth. Browse the rest of the Empire UI blog for guides on search, infinite scroll, and component patterns that pair naturally with this sidebar.
FAQ
Not for the filter state itself. URL search params are better — they're shareable, bookmarkable, and survive page refreshes. React Query or SWR are great for fetching products based on those params, but keep the filter values in the URL, not in a store.
Add a text input above the checkbox list to search within options, and only render visible items. A useMemo that filters options against a local query state is all you need — no virtualization required unless you're dealing with hundreds of options.
Yes. Import useSearchParams, useRouter, and usePathname from next/navigation, then call router.push(pathname + '?' + params.toString()) instead of setSearchParams. Wrap any component using useSearchParams in a Suspense boundary or Next.js will throw a build error.
Use useDebouncedCallback from the use-debounce package (1.5 KB) or write a simple useDebounce hook. Wrap just the setFilters call — the slider's visual position should update immediately on every drag event, only the URL write and the downstream product fetch should be debounced.