javascript 124 lines · 3 tabs

Sync React Form State to the URL Query String with a Debounced useSearchParams Hook

Shared by codesnips Jul 2026
3 tabs
import { useCallback, useEffect, useState } from 'react';

function parseSearch(search) {
  const params = new URLSearchParams(search);
  const out = {};
  for (const [key, value] of params.entries()) out[key] = value;
  return out;
}

function buildSearch(state) {
  const params = new URLSearchParams();
  Object.entries(state).forEach(([key, value]) => {
    if (value !== '' && value != null) params.set(key, String(value));
  });
  const qs = params.toString();
  return qs ? '?' + qs : window.location.pathname;
}

export function useUrlState() {
  const [state, setInternal] = useState(() => parseSearch(window.location.search));

  const setState = useCallback((patch, { push = false } = {}) => {
    setInternal((prev) => {
      const next = { ...prev, ...patch };
      const url = buildSearch(next);
      if (push) window.history.pushState(next, '', url);
      else window.history.replaceState(next, '', url);
      return next;
    });
  }, []);

  useEffect(() => {
    const onPop = () => setInternal(parseSearch(window.location.search));
    window.addEventListener('popstate', onPop);
    return () => window.removeEventListener('popstate', onPop);
  }, []);

  return [state, setState];
}
3 files · javascript Explain with highlit

This snippet shows how to keep a filter form's state in sync with the browser URL so that searches are shareable, bookmarkable, and survive a page refresh. Instead of storing filters only in component state, the URL becomes the single source of truth, and the form simply reflects and mutates the query string.

The useUrlState hook tab wraps URLSearchParams in a small stateful API. It seeds its initial value by parsing window.location.search, then exposes a setState that merges partial updates, drops empty values, and rewrites the URL. A key detail is that it uses history.replaceState for high-frequency updates (like typing) and history.pushState only when the caller opts in, avoiding a flood of history entries the user would have to click through. It also registers a popstate listener so browser back/forward buttons re-hydrate the state, and cleans that listener up on unmount.

The useDebouncedUrlState hook tab composes on top of that. It keeps a synchronous draft for snappy input rendering while debouncing the actual URL write through a setTimeout, so typing does not thrash the address bar or trigger a network request on every keystroke. The flush helper cancels the pending timer and commits immediately, which the form uses on submit.

The ProductFilters component tab ties it together. It reads q, category, and sort straight from the URL-backed draft and treats them as controlled inputs. Text typing goes through the debounced path, while the select changes call commit directly since a discrete choice should update the URL at once. Submitting the form calls flush and pushes a real history entry so the result is a durable, back-button-friendly link.

The main trade-off is that the URL only cleanly represents flat string values; nested or large state needs encoding and can bloat links. Empty-value pruning keeps URLs tidy, and reading from location.search on mount makes deep links work. This pattern fits search pages, dashboards, and any filter UI where the current view should be a copyable address.


Related snips

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
html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Form Validation Example</title>
  <style>

HTML forms with validation and accessibility

html forms validation
by Alex Chang 1 tab
javascript
import { Application } from "@hotwired/stimulus"
import FormSubmitController from "./controllers/form_submit_controller"

const application = Application.start()
application.debug = false

Disable submit button while Turbo form is submitting

rails hotwire stimulus
by codesnips 3 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 React Form State to the URL Query String with a Debounced useSearchParams Hook — share card
Link copied