javascript 129 lines · 3 tabs

Sync Filter State to the URL with a useSearchParams-Driven Filter Bar in React Router

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

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

ruby
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

rails turbo hotwire
by codesnips 4 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 Filter State to the URL with a useSearchParams-Driven Filter Bar in React Router — share card
Link copied