import { useMemo, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
function parseFilters(params) {
return {
q: params.get('q') || '',
status: params.get('status') || 'all',
inStock: params.get('inStock') === 'true',
page: Number(params.get('page') || '1'),
};
}
export function useFilters() {
const [searchParams, setSearchParams] = useSearchParams();
const filters = useMemo(
() => parseFilters(searchParams),
[searchParams]
);
const setFilter = useCallback(
(key, value, { replace = false } = {}) => {
const next = new URLSearchParams(searchParams);
if (value === '' || value == null || value === false || value === 'all') {
next.delete(key);
} else {
next.set(key, String(value));
}
// any filter change invalidates the current page
if (key !== 'page') next.delete('page');
setSearchParams(next, { replace });
},
[searchParams, setSearchParams]
);
const clearFilters = useCallback(
() => setSearchParams(new URLSearchParams(), { replace: true }),
[setSearchParams]
);
return { filters, setFilter, clearFilters };
}
import { useEffect, useState } from 'react';
import { useFilters } from './useFilters';
import { useDebouncedCallback } from './useDebouncedCallback';
const STATUSES = ['all', 'active', 'archived'];
export function FilterBar() {
const { filters, setFilter, clearFilters } = useFilters();
const [text, setText] = useState(filters.q);
// resync local mirror when q changes from outside (e.g. Clear)
useEffect(() => {
setText(filters.q);
}, [filters.q]);
const pushQuery = useDebouncedCallback((value) => {
setFilter('q', value, { replace: true });
}, 300);
const onSearch = (e) => {
setText(e.target.value);
pushQuery(e.target.value);
};
return (
<div className="filter-bar">
<input
type="search"
placeholder="Search products\u2026"
value={text}
onChange={onSearch}
/>
<select
value={filters.status}
onChange={(e) => setFilter('status', e.target.value)}
>
{STATUSES.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<label>
<input
type="checkbox"
checked={filters.inStock}
onChange={(e) => setFilter('inStock', e.target.checked)}
/>
In stock only
</label>
<button type="button" onClick={clearFilters}>
Clear
</button>
</div>
);
}
import { useCallback, useEffect, useRef } from 'react';
export function useDebouncedCallback(fn, delay) {
const fnRef = useRef(fn);
const timerRef = useRef(null);
// keep the latest callback without resetting the timer
useEffect(() => {
fnRef.current = fn;
}, [fn]);
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
return useCallback(
(...args) => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
fnRef.current(...args);
}, delay);
},
[delay]
);
}
This snippet shows how to make filter state live in the URL rather than in component state, so that a filtered view is shareable, bookmarkable, and survives a page refresh or back-button press. It uses React Router's useSearchParams as the single source of truth and layers a small custom hook on top to keep components clean.
The useFilters hook tab wraps useSearchParams and exposes a plain object derived from the query string via parseFilters, plus typed setters. Reads are memoized with useMemo keyed on searchParams.toString() so a stable filter object is only recomputed when the URL actually changes. The setFilter callback builds a fresh URLSearchParams from the current entries, then applies changes: empty or falsy values are deleted so the URL stays clean instead of accumulating ?q=&status=. A key detail is that changing any filter resets page back to 1, because leaving a stale page number would show an empty result set. Navigation uses setSearchParams(next, { replace: true }) for the debounced text case so rapid typing does not flood the browser history.
The FilterBar component tab is fully controlled: every input derives its value from filters, never from local useState, which guarantees the UI and URL can never drift apart. The search box is the one exception that needs care — writing to the URL on every keystroke is jarring, so useDebouncedCallback delays the setFilter('q', ...) write by 300ms while the input itself stays responsive through a local mirror that resyncs when filters.q changes externally (for example via the Clear button). Select and checkbox controls write immediately since they are discrete.
The useDebouncedCallback hook tab is a minimal, correct debounce built on useRef to hold the timer and useEffect to clear it on unmount, avoiding the stale-closure and leaked-timer bugs common in ad-hoc implementations. The trade-off of URL-as-state is that values are strings and must be parsed and coerced, and deeply nested state is awkward — but for a filter bar it gives deep-linkable views essentially for free and pairs naturally with server-side data fetching keyed on the same params.
Related snips
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
Debounced live search with Stimulus + Turbo Streams
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
timeout: 15000,
Axios API client with interceptors
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface FilterState {
search: string
category: string | null
Zustand for lightweight state management
import SwiftUI
struct ContentView: View {
@State private var username = ""
@State private var isLoggedIn = false
@StateObject private var viewModel = LoginViewModel()
SwiftUI declarative UI with state management
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
url: String,
delay: { type: Number, default: 800 },
Stimulus: autosave draft with Turbo-friendly requests
Share this code
Here's the card — post it anywhere.