import { useCallback, useEffect, useState } from "react";
type Patch = Record<string, string | null | undefined>;
function readParams(): URLSearchParams {
return new URLSearchParams(window.location.search);
}
export function useSearchParamsState() {
const [params, setLocalParams] = useState<URLSearchParams>(readParams);
useEffect(() => {
const sync = () => setLocalParams(readParams());
window.addEventListener("popstate", sync);
window.addEventListener("pushstate", sync);
window.addEventListener("replacestate", sync);
return () => {
window.removeEventListener("popstate", sync);
window.removeEventListener("pushstate", sync);
window.removeEventListener("replacestate", sync);
};
}, []);
const setParams = useCallback((patch: Patch, mode: "push" | "replace" = "push") => {
const next = readParams();
for (const [key, value] of Object.entries(patch)) {
if (value === null || value === undefined || value === "") {
next.delete(key);
} else {
next.set(key, value);
}
}
const query = next.toString();
const url = query ? `${window.location.pathname}?${query}` : window.location.pathname;
if (mode === "replace") {
window.history.replaceState(null, "", url);
window.dispatchEvent(new Event("replacestate"));
} else {
window.history.pushState(null, "", url);
window.dispatchEvent(new Event("pushstate"));
}
}, []);
return { params, setParams };
}
import { useCallback, useMemo, useRef } from "react";
import { useSearchParamsState } from "./useSearchParamsState";
export interface Filters {
q: string;
category: string;
min?: number;
max?: number;
tags: string[];
}
function useDebouncedCallback<A extends unknown[]>(fn: (...args: A) => void, delay: number) {
const timer = useRef<ReturnType<typeof setTimeout>>();
return useCallback((...args: A) => {
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => fn(...args), delay);
}, [fn, delay]);
}
function toNumber(value: string | null): number | undefined {
if (!value) return undefined;
const n = Number(value);
return Number.isFinite(n) ? n : undefined;
}
export function useFilters() {
const { params, setParams } = useSearchParamsState();
const filters: Filters = useMemo(() => ({
q: params.get("q") ?? "",
category: params.get("category") ?? "",
min: toNumber(params.get("min")),
max: toNumber(params.get("max")),
tags: (params.get("tags") ?? "").split(",").filter(Boolean),
}), [params]);
const setFilter = useCallback(<K extends keyof Filters>(key: K, value: Filters[K]) => {
const serialized = Array.isArray(value) ? value.join(",") : value == null ? "" : String(value);
setParams({ [key]: serialized });
}, [setParams]);
const setQuery = useDebouncedCallback((q: string) => {
setParams({ q }, "replace");
}, 300);
const toggleTag = useCallback((tag: string) => {
const next = filters.tags.includes(tag)
? filters.tags.filter((t) => t !== tag)
: [...filters.tags, tag];
setParams({ tags: next.join(",") });
}, [filters.tags, setParams]);
return { filters, setFilter, setQuery, toggleTag };
}
import { useFilters } from "./useFilters";
const CATEGORIES = ["all", "books", "electronics", "toys"];
const TAGS = ["sale", "new", "popular", "eco"];
export function FilterPanel() {
const { filters, setFilter, setQuery, toggleTag } = useFilters();
return (
<aside className="filter-panel">
<input
type="search"
placeholder="Search products"
defaultValue={filters.q}
onChange={(e) => setQuery(e.target.value)}
/>
<select
value={filters.category || "all"}
onChange={(e) => setFilter("category", e.target.value === "all" ? "" : e.target.value)}
>
{CATEGORIES.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<div className="price-range">
<input
type="number"
placeholder="Min"
value={filters.min ?? ""}
onChange={(e) => setFilter("min", e.target.value ? Number(e.target.value) : undefined)}
/>
<input
type="number"
placeholder="Max"
value={filters.max ?? ""}
onChange={(e) => setFilter("max", e.target.value ? Number(e.target.value) : undefined)}
/>
</div>
<div className="tags">
{TAGS.map((tag) => (
<button
key={tag}
type="button"
aria-pressed={filters.tags.includes(tag)}
className={filters.tags.includes(tag) ? "tag active" : "tag"}
onClick={() => toggleTag(tag)}
>
{tag}
</button>
))}
</div>
</aside>
);
}
This snippet shows how to make a product filter panel fully driven by the URL query string, so filters become shareable, bookmarkable, and survive a page reload. The core idea is that the URL is the single source of truth: instead of holding filter state in component state and mirroring it into the address bar, the components read directly from window.location.search and write back through the History API.
The useSearchParamsState hook tab wraps the browser's native URLSearchParams and subscribes to navigation events. It listens for the popstate event (fired on back/forward) plus a custom pushstate/replacestate event so that programmatic updates in one component re-render others reading the same URL. The setParams callback takes a partial patch, applies it onto a fresh URLSearchParams, deletes keys whose value is empty, and calls either history.pushState or history.replaceState. Using replace for high-frequency updates like a text search avoids polluting the back stack, while discrete toggles use push so the back button undoes them.
Because URLSearchParams is untyped, the hook exposes raw string access; the useFilters hook tab layers a typed shape on top. It parses q, category, min/max, and a comma-joined tags list into a Filters object, and provides setFilter and toggleTag helpers that serialize values back. The text query is debounced through useDebouncedCallback so keystrokes use replace and don't spam history, whereas category and tag changes commit immediately.
The FilterPanel component tab is then almost stateless: it renders inputs bound to the derived filters object and calls the typed setters on change. Note how min/max are treated as numbers or undefined, and how clearing an input naturally removes the key from the URL because empty values are pruned in setParams.
The main trade-off is that every update touches global history, so debouncing and choosing push versus replace deliberately matters. A pitfall to watch is server rendering: reading window must be guarded or deferred to useEffect, and rapidly firing pushState without debounce can make the back button unusable. This pattern shines whenever UI state should be linkable rather than ephemeral.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
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.