typescript 157 lines · 3 tabs

Sync a Filter Panel to the URL Query String with a Custom useSearchParams Hook

Shared by codesnips Jul 2026
3 tabs
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 };
}
3 files · typescript Explain with highlit

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

typescript
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

typescript reliability retry
by codesnips 2 tabs
javascript
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

rails hotwire stimulus
by codesnips 4 tabs
typescript
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

react axios api
by Maya Patel 1 tab
typescript
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

interface FilterState {
  search: string
  category: string | null

Zustand for lightweight state management

react zustand state-management
by Maya Patel 2 tabs
swift
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

swift swiftui ios
by Sofia Martinez 2 tabs
javascript
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

rails stimulus hotwire
by codesnips 3 tabs

Share this code

Here's the card — post it anywhere.

Sync a Filter Panel to the URL Query String with a Custom useSearchParams Hook — share card
Link copied