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.
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 stateThe 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.
// Traditional state (lost on refresh)const [filters, setFilters] = useState({ price: 100, sort: 'asc' })
// URL-based state (persistent, shareable)// URL becomes: /products?price=100&sort=ascconst 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:
- Built-in Persistence - State survives page refreshes and browser history navigation
- Native Shareability - Users can copy-paste URLs to share exact application state
- Bookmarkable - Save specific views for later reference
- Framework Agnostic - Works in vanilla JS, React, Vue, or any framework
- SEO-Friendly - Search engines can index different application states
- 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
// 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=abc123I 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.
// WRONG - Replaces ALL existing paramsnavigate({ search: { page: 2 } })// Before: /products?category=electronics&sort=asc// After: /products?page=2 (category and sort gone!)
// CORRECT - Preserve existing paramsnavigate({ search: (prev) => ({ ...prev, page: 2 })})// After: /products?category=electronics&sort=asc&page=2The fix: spread the previous params before adding new ones.
Mistake 3: Not Validating URL Params
My third issue: users could craft invalid URLs.
// WRONG - Direct use can cause errorsconst page = params.get('page') // Could be null, "abc", "-5"
// CORRECT - Always validateconst page = parseInt(params.get('page')) || 1const 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.
// WRONG - Spaces and special characters break URLsconst search = "user query with spaces"window.location.href = `/search?q=${search}`// Result: /search?q=user query with spaces (broken!)
// CORRECT - Use URLSearchParams for encodingconst 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.
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:
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.
import { createRootRoute, createRoute } from '@tanstack/react-router'import { z } from 'zod'
// Define schema for type-safe search paramsconst 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 paramsconst 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.
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:
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:
- 👨💻 MDN URLSearchParams Documentation
- 👨💻 TanStack Router Search Params Guide
- 👨💻 React Router useSearchParams Hook
- 👨💻 Reddit Discussion: Search Params as State
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments