typescript 146 lines · 3 tabs

Sync Typed Form State to the URL Query String with a useQueryState Hook

Shared by codesnips Aug 2026
3 tabs
export interface QueryCodec<T> {
  parse: (raw: string | null) => T;
  serialize: (value: T) => string | null;
}

export function stringParam(fallback = ""): QueryCodec<string> {
  return {
    parse: (raw) => raw ?? fallback,
    serialize: (value) => (value === fallback ? null : value),
  };
}

export function numberParam(fallback: number): QueryCodec<number> {
  return {
    parse: (raw) => {
      if (raw === null) return fallback;
      const n = Number(raw);
      return Number.isNaN(n) ? fallback : n;
    },
    serialize: (value) => (value === fallback ? null : String(value)),
  };
}

export function booleanParam(fallback = false): QueryCodec<boolean> {
  return {
    parse: (raw) => (raw === null ? fallback : raw === "true"),
    serialize: (value) => (value === fallback ? null : String(value)),
  };
}

export function enumParam<T extends string>(
  allowed: readonly T[],
  fallback: T
): QueryCodec<T> {
  return {
    parse: (raw) => (allowed.includes(raw as T) ? (raw as T) : fallback),
    serialize: (value) => (value === fallback ? null : value),
  };
}
3 files · typescript Explain with highlit

This snippet shows how filter/form state can live in the URL query string instead of component state, so a page is shareable, bookmarkable, and survives a refresh. The core idea is a small codec layer plus a typed hook that reads and writes URLSearchParams while staying framework-agnostic about how the URL actually changes.

The queryCodecs tab defines the serialization contract. A QueryCodec<T> is just a parse/serialize pair, and factory functions like stringParam, numberParam, enumParam, and booleanParam produce codecs with sensible fallbacks. Keeping parsing here matters because query strings are always strings and always untrusted — a user can hand-edit ?page=banana, so numberParam returns a fallback when Number yields NaN rather than propagating a bad value into the UI. enumParam restricts values to a known set, which is exactly what typed filters need.

The useQueryState hook tab wires a codec to a single query key. It reads the live URLSearchParams via a getSearchString accessor and subscribes to popstate so browser back/forward stay in sync. The returned setValue supports a functional updater like useState, computes the next params, and drops the key entirely when the value equals the codec's default via serialize returning null — this keeps URLs clean instead of accumulating ?sort=default. Writes go through an injected navigate callback so the hook works with the History API, React Router, or Next.js without hard-coding one.

The FilterBar component tab composes several useQueryState calls into a real filter form. Note the search field uses debounce from lodash so each keystroke does not spam history.pushState, while discrete controls like the sort dropdown and the in-stock checkbox update immediately. Because every control is backed by the URL, the parent list component can derive its query purely from useSearchParams, and no duplicate useState is needed.

The main trade-offs: URL state is stringly-typed and size-limited, so it suits filters and pagination rather than large or sensitive data, and rapid updates should be debounced or use replace to avoid polluting history. The codec pattern isolates all of that fragility in one tested place.


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
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

Share this code

Here's the card — post it anywhere.

Sync Typed Form State to the URL Query String with a useQueryState Hook — share card
Link copied