Skip to content

How to Use URL Search Params for State Management in JavaScript

I spent hours configuring filters on a product listing page—category, price range, sort order, stock availability. Then I hit refresh. Everything disappeared. My colleague asked me to share a link so they could see the exact view I was working with. I couldn’t.

That’s when I realized: my state was trapped inside React’s useState, invisible to the URL.

The Problem with Traditional State

Traditional state management in JavaScript applications has a fundamental limitation: it’s ephemeral.

ephemeral-state.js
const [filters, setFilters] = useState({
category: 'electronics',
minPrice: 50,
maxPrice: 500,
sort: 'price-asc'
})
// User refreshes browser → filters disappear
// User wants to share view → can't share state
// User bookmarks page → bookmark shows default state

The URL stays clean: /products. But the user’s carefully configured filters vanish on refresh. This creates a poor user experience and makes debugging harder—you can’t reproduce a specific state by sharing a URL.

I tried adding Redux for persistence. But for simple filters, Redux felt like overkill. I was writing actions, reducers, selectors, and middleware just to persist a few filter values.

Then someone on Reddit mentioned something that stuck with me: “My mind immediately went to SearchParams tbh. I use that for state even with React lol.”

URL Search Params as State

The URLSearchParams API is a native browser interface that lets you read, write, and manipulate URL query parameters. Combined with router hooks, it becomes a powerful state management solution.

url-state-basic.js
// Traditional state (lost on refresh)
const [filters, setFilters] = useState({ price: 100, sort: 'asc' })
// URL-based state (persistent, shareable)
// URL becomes: /products?price=100&sort=asc
const params = new URLSearchParams(window.location.search)
const filters = {
price: params.get('price'), // "100"
sort: params.get('sort') // "asc"
}

Now when the user refreshes, the filters survive. When they share the URL, the recipient sees the exact same view. When they bookmark, the bookmark preserves the state.

Why This Works

URL search params are effectively a form of global state living inside the URL. TanStack Router’s documentation puts it well:

“Search params are effectively a form of global state living inside the URL. Storing specific pieces of state in the URL is valuable because it allows users to open links in new tabs, bookmark pages, and share links with the assurance that they will see the exact state they expected.”

The benefits stack up:

  1. Built-in Persistence - State survives page refreshes and browser history navigation
  2. Native Shareability - Users can copy-paste URLs to share exact application state
  3. Bookmarkable - Save specific views for later reference
  4. Framework Agnostic - Works in vanilla JS, React, Vue, or any framework
  5. SEO-Friendly - Search engines can index different application states
  6. Zero Dependencies - Native browser API, no bundle size impact

My Trial and Error Journey

I started by naively replacing useState with URL params everywhere. That was my first mistake.

Mistake 1: Putting Everything in the URL

mistake-everything-url.js
// WRONG - Don't store sensitive data in URL
<Link to="." search={{ password, sessionId, tempHoverState }} />
// URLs become visible in browser history, logs, network requests
// Anyone can see: /login?password=mypassword&sessionId=abc123

I learned quickly: URLs are public. Don’t store passwords, tokens, or sensitive data there. Also, URLs have length limits (~2000 characters), so large objects won’t fit.

Mistake 2: Losing Previous Params

My second attempt: updating one filter wiped out all the others.

mistake-losing-params.js
// WRONG - Replaces ALL existing params
navigate({ search: { page: 2 } })
// Before: /products?category=electronics&sort=asc
// After: /products?page=2 (category and sort gone!)
// CORRECT - Preserve existing params
navigate({
search: (prev) => ({ ...prev, page: 2 })
})
// After: /products?category=electronics&sort=asc&page=2

The fix: spread the previous params before adding new ones.

Mistake 3: Not Validating URL Params

My third issue: users could craft invalid URLs.

mistake-no-validation.js
// WRONG - Direct use can cause errors
const page = params.get('page') // Could be null, "abc", "-5"
// CORRECT - Always validate
const page = parseInt(params.get('page')) || 1
const sort = ['asc', 'desc'].includes(params.get('sort'))
? params.get('sort')
: 'asc'

Anyone can type /products?page=abc directly. Your code needs to handle invalid input gracefully.

Mistake 4: Ignoring URL Encoding

My fourth mistake broke URLs with spaces and special characters.

mistake-url-encoding.js
// WRONG - Spaces and special characters break URLs
const search = "user query with spaces"
window.location.href = `/search?q=${search}`
// Result: /search?q=user query with spaces (broken!)
// CORRECT - Use URLSearchParams for encoding
const params = new URLSearchParams({ q: search })
window.location.href = `/search?${params}`
// Result: /search?q=user+query+with+spaces (proper encoding)

URLSearchParams handles encoding automatically. Use it instead of manual string concatenation.

Building a Custom Hook

After learning from my mistakes, I built a reusable hook for URL state in React.

useURLState-hook.js
import { useState, useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
function useURLState(initialState) {
const location = useLocation()
const navigate = useNavigate()
// Parse current URL params
const getParamsFromURL = () => {
const params = new URLSearchParams(location.search)
const state = {}
for (const [key, value] of params) {
state[key] = value
}
return { ...initialState, ...state }
}
const [state, setState] = useState(getParamsFromURL)
// Update URL when state changes
const updateState = (newState) => {
const mergedState = { ...state, ...newState }
setState(mergedState)
// Convert state to URL params
const params = new URLSearchParams()
Object.entries(mergedState).forEach(([key, value]) => {
if (value !== null && value !== undefined && value !== '') {
params.set(key, value)
}
})
// Update URL without reload
navigate(`${location.pathname}?${params.toString()}`, { replace: true })
}
return [state, updateState]
}

Using it in a product list component:

product-list-usage.js
function ProductList() {
const [filters, setFilters] = useURLState({
category: '',
sort: 'newest',
page: 1
})
return (
<div>
<select
value={filters.category}
onChange={(e) => setFilters({ category: e.target.value, page: 1 })}
>
<option value="">All Categories</option>
<option value="electronics">Electronics</option>
<option value="books">Books</option>
</select>
<p>Current filters: {JSON.stringify(filters)}</p>
<p>Share this URL to preserve your filters!</p>
</div>
)
}

Now the filters persist in the URL. Users can refresh, share, and bookmark.

Going Further with TanStack Router

For production applications, I switched to TanStack Router. It provides type-safe search params with built-in validation using Zod schemas.

tanstack-router-schema.js
import { createRootRoute, createRoute } from '@tanstack/react-router'
import { z } from 'zod'
// Define schema for type-safe search params
const productSearchSchema = z.object({
page: z.number().int().min(1).catch(1),
sort: z.enum(['newest', 'price-asc', 'price-desc']).catch('newest'),
category: z.string().optional(),
minPrice: z.number().min(0).optional(),
maxPrice: z.number().max(10000).optional(),
inStock: z.boolean().catch(false)
})
// Create route with validated search params
const productsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/products',
validateSearch: productSearchSchema
})

The catch method provides fallback values when params are invalid. If someone types page=-5, it defaults to 1.

tanstack-router-usage.js
function ProductsPage() {
const search = productsRoute.useSearch() // Fully typed!
const navigate = useNavigate({ from: '/products' })
const updateSort = (newSort) => {
navigate({
search: (prev) => ({ ...prev, sort: newSort, page: 1 })
})
}
const resetFilters = () => {
navigate({
search: { page: 1, sort: 'newest', inStock: false }
})
}
return (
<div>
<SortDropdown value={search.sort} onChange={updateSort} />
<Pagination currentPage={search.page} />
<button onClick={resetFilters}>Reset All Filters</button>
</div>
)
}

The type safety catches errors at compile time. The search object is fully typed based on the Zod schema.

When to Use URL State (and When Not To)

URL state is ideal for:

  • Filters and sorting - E-commerce, data tables, search results
  • Pagination - Page numbers in lists
  • UI preferences - Active tabs, open/closed panels, view modes
  • Search queries - Search terms, applied filters
  • User selections - Multi-step wizards, configuration tools

Avoid URL state for:

  • Sensitive data - Passwords, tokens, personal information
  • Large objects - URLs have length limits
  • Temporary UI state - Hover states, unsaved form inputs
  • Real-time data - Rapidly changing values

Complete Example: E-commerce Filters

Here’s a complete filter component that demonstrates all the patterns:

ecommerce-filters.js
import { useSearchParams } from 'react-router-dom'
function ProductFilters() {
const [searchParams, setSearchParams] = useSearchParams()
// Parse current filters from URL
const filters = {
category: searchParams.get('category') || '',
minPrice: parseInt(searchParams.get('minPrice')) || 0,
maxPrice: parseInt(searchParams.get('maxPrice')) || 1000,
inStock: searchParams.get('inStock') === 'true',
sort: searchParams.get('sort') || 'newest',
page: parseInt(searchParams.get('page')) || 1
}
// Update single filter (preserves others)
const updateFilter = (key, value) => {
const newParams = new URLSearchParams(searchParams)
if (value === null || value === '' || value === undefined) {
newParams.delete(key)
} else {
newParams.set(key, String(value))
}
// Reset to page 1 when filters change
if (key !== 'page' && key !== 'sort') {
newParams.set('page', '1')
}
setSearchParams(newParams)
}
// Clear all filters
const clearFilters = () => {
setSearchParams({ page: '1', sort: 'newest' })
}
// Generate shareable URL
const shareURL = `${window.location.origin}${window.location.pathname}?${searchParams.toString()}`
return (
<div className="filters">
<h3>Filters</h3>
<label>
Category:
<select
value={filters.category}
onChange={(e) => updateFilter('category', e.target.value)}
>
<option value="">All</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
</select>
</label>
<label>
<input
type="checkbox"
checked={filters.inStock}
onChange={(e) => updateFilter('inStock', e.target.checked)}
/>
In Stock Only
</label>
<button onClick={clearFilters}>Clear All Filters</button>
<div className="share-section">
<p>Shareable URL:</p>
<input type="text" readOnly value={shareURL} />
<button onClick={() => navigator.clipboard.writeText(shareURL)}>
Copy Link
</button>
</div>
</div>
)
}

This component preserves all filters in the URL, handles invalid input gracefully, and provides a shareable link feature.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments