Skip to content

TanStack Router vs useState: Why URL State Management Changes Everything

I spent an hour on a call with my product manager trying to reproduce a bug. “Click the filters, set category to electronics, sort by price, go to page 3.” Every time she refreshed the page, her filters vanished. I realized the problem: my state was trapped in memory.

I was using useState for everything. Filters, pagination, search terms—all locked in component state, lost on refresh, impossible to share. Then I discovered TanStack Router’s URL state management, and it changed how I think about application state.

The Problem: State Trapped in Memory

Here’s what my product list page looked like:

useState - State dies on refresh
function ProductList() {
const [filters, setFilters] = useState({
category: 'all',
sort: 'name',
page: 1
})
// User sets filters, navigates around
// Refreshes page
// BOOM - all filters lost
return (
<div>
<FilterBar filters={filters} onChange={setFilters} />
<ProductGrid filters={filters} />
</div>
)
}

Every time the user:

  • Refreshes the page → state gone
  • Copies URL to share → recipient sees default state
  • Bookmarks the page → bookmark has no state
  • Uses back button → previous state lost

I had users emailing me: “I had it filtered by category X, sorted by price, on page 5. Now I can’t get back there.”

The root cause was clear: state belongs in the URL, not in memory.

The Solution: TanStack Router’s URL-First Architecture

I migrated to TanStack Router and moved all that state to URL query parameters:

TanStack Router - State survives refresh
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
// Define schema for type safety
const searchSchema = z.object({
category: z.string().default('all'),
sort: z.enum(['name', 'price', 'date']).default('name'),
page: z.number().int().min(1).default(1)
})
export const Route = createFileRoute('/products')({
validateSearch: searchSchema,
component: ProductList
})
function ProductList() {
const search = Route.useSearch()
const navigate = Route.useNavigate()
// State is in URL: /products?category=electronics&sort=price&page=3
// Refresh? State preserved
// Share URL? Recipient sees exact same view
// Bookmark? Bookmark includes full state
const setFilters = (newFilters: Partial&lt;typeof search&gt;) => {
navigate({
search: (prev) => ({ ...prev, ...newFilters })
})
}
return (
<div>
<FilterBar filters={search} onChange={setFilters} />
<ProductGrid filters={search} />
</div>
)
}

The difference? State is now in the URL, which means it’s:

  • Persistent across refreshes
  • Shareable via URL
  • Bookmarkable for later
  • Browser-native (back button works)

Why This Matters: Real-World Benefits

After six months of using URL state, here’s what changed:

Before (useState):

User frustration with useState state
User: "I spent 20 minutes filtering products to find the right one"
User: "Then I accidentally refreshed"
User: "Now I have to start over"
Support ticket: "Can you make filters persist?"
Developer: "That would require local storage or URL params"
Developer: "Let me add that to the backlog"

After (URL state):

Seamless experience with URL state
User: Sets filters, copies URL, sends to colleague
Colleague: Opens URL, sees exact same filtered view
User: Refreshes page, filters still there
User: Bookmarks page, returns next week, state restored
Support ticket: "Why didn't we do this earlier?"

The URLs became meaningful:

URLs now tell a story
Before: /products
After: /products?category=electronics&sort=price&page=3
Before: /users
After: /users?status=active&department=engineering&sort=name

Each URL is now a snapshot of application state.

The “Aha” Moment: When It Clicked

I struggled at first. I kept using useState for things that should be in the URL. Then I read a Reddit comment from user voltron2112:

“It took me awhile for it to click, but once it did, it’s hard for me to go back to next/react-router. If you are happy storing state in useState or whatever data store, then you probably won’t see much advantage. But if you start transferring that state to URL query params it really shines.”

That was it. I needed to change my mental model:

Mental model shift
OLD MINDSET:
├── Filters? useState
├── Pagination? useState
├── Search term? useState
└── Current tab? useState
NEW MINDSET:
├── Filters? URL params (shareable)
├── Pagination? URL params (bookmarkable)
├── Search term? URL params (deep-linkable)
└── Current tab? URL params (refresh-proof)

Not everything belongs in the URL. But for state that represents “view configuration”—filters, sort order, pagination, search—URL is the right place.

One of my favorite features: updating one filter without losing others:

Preserving state during navigation
// User clicks "Electronics" category button
// Only category changes, sort and page stay the same
<Link
to="/products"
search={(prev) => ({ ...prev, category: 'electronics' })}
>
Electronics
</Link>
// User clicks "Next page"
// Only page changes, category and sort preserved
<button
onClick={() => navigate({
search: (prev) => ({ ...prev, page: prev.page + 1 })
})}
>
Next Page
</button>
// User clicks "Sort by Price"
// Only sort changes, category and page preserved
<button
onClick={() => navigate({
search: (prev) => ({ ...prev, sort: 'price', page: 1 })
})}
>
Sort by Price
</button>

This was a nightmare with useState. I had to carefully track which parts of state to preserve:

The useState nightmare
// This is what I used to do
const [filters, setFilters] = useState(initialFilters)
// Filter by category
setFilters({ ...filters, category: 'electronics' })
// Wait, did I preserve sort? Yes.
// Did I preserve page? Oops, forgot to reset page to 1
// Next page
setFilters({ ...filters, page: filters.page + 1 })
// Wait, what if category changed while I was on page 5?
// Do I reset to page 1? Keep it? Logic gets complex
// This became unmaintainable

With URL state, the browser URL is the source of truth. Each navigation updates the URL, and the component reads from the URL. No synchronization issues.

Loader Integration for Derived State

Another benefit: loaders can use URL state to fetch the right data:

Loader with URL state
export const Route = createFileRoute('/products')({
validateSearch: searchSchema,
loaderDeps: ({ search }) => ({ search }),
loader: async ({ deps }) => {
// deps.search is typed and validated
const products = await fetchProducts(deps.search)
return { products }
},
component: ProductList
})
function ProductList() {
const { products } = Route.useLoaderData()
const search = Route.useSearch()
// products are already fetched with current filters
// No need to useQuery with search as dependency
// URL changes → loader re-runs → data updates
}

This eliminated a whole class of bugs where I’d forget to add a dependency to my query key:

The old way - easy to miss dependencies
// Easy to forget adding 'category' to queryKey
const { data } = useQuery({
queryKey: ['products', filters.sort, filters.page],
// Oops, forgot filters.category
// Query doesn't refetch when category changes
queryFn: () => fetchProducts(filters)
})

Common Mistakes I Made

Mistake 1: Mixing useState with URL state

I tried to use both for the same data:

DO NOT mix state sources
// This is bad
const [localCategory, setLocalCategory] = useState('all')
const search = Route.useSearch() // has category
// Now I have TWO sources of truth
// Which one do I use?
// How do I sync them?

Solution: Pick one source of truth. For view configuration, URL wins.

Mistake 2: Not defining proper schemas

I skipped Zod at first:

Without validation
// This works but has no validation
validateSearch: (search) => ({
page: Number(search?.page || 1),
sort: search?.sort || 'name'
})
// User types ?page=abc
// page becomes NaN
// Everything breaks

Solution: Use Zod for validation:

With Zod validation
validateSearch: z.object({
page: z.number().int().min(1).default(1),
sort: z.enum(['name', 'price']).default('name')
})
// User types ?page=abc
// Zod returns default value (1)
// No NaN issues

Mistake 3: Over-engineering simple views

Not every piece of UI needs URL state:

When to use URL state
USE URL STATE FOR:
├── Filters and search
├── Pagination
├── Sort order
├── Active tab (if shareable)
├── Modal open state (if deep-linkable)
└── Any state that defines "what I'm looking at"
KEEP IN useState FOR:
├── Form input values (before submit)
├── Dropdown open/closed state
├── Temporary UI feedback (toasts, spinners)
├── Hover states
└── Non-shareable ephemeral state

Migration Strategy

If you’re using useState for view configuration, here’s how to migrate:

Migration approach
Phase 1: Identify URL-worthy state
├── Audit all useState calls
├── Flag state that represents "view configuration"
└── List: filters, pagination, sort, search terms
Phase 2: Define schemas
├── Create Zod schema for each route's search params
├── Add defaults for all fields
└── Consider validation rules (enums, ranges)
Phase 3: Replace useState with useSearch
├── Remove useState declarations
├── Read from useSearch() instead
└── Replace setState with navigate({ search: ... })
Phase 4: Update links and buttons
├── Replace onClick handlers with Link/search
├── Test each navigation flow
└── Verify URL updates correctly
Phase 5: Test edge cases
├── Refresh page with filters set
├── Share URL with colleague
├── Bookmark and return later
└── Test back/forward buttons

Performance Considerations

I worried about URL parsing performance. Turns out, it’s negligible:

Performance comparison
useState:
├── State update: ~0.1ms
├── Re-render triggered
└── No URL history
URL state:
├── Navigation: ~0.5ms (includes URL update)
├── Re-render triggered
├── Browser history updated
└── Shareable link created
Difference: ~0.4ms for massive UX improvement

The real performance win: users can share URLs, reducing support requests like “I can’t find that filtered view again.”

When This Doesn’t Apply

URL state isn’t always the answer:

  • Sensitive data: Don’t put passwords, tokens, or PII in URLs
  • Large state: URLs have length limits (browsers vary, ~2000+ chars is safe)
  • Non-shareable state: Temporary UI state that no one else needs
  • Complex nested objects: Use param encoding or store ID, fetch full object
URL state limits
// Bad: Too large for URL
search: {
products: [{ id: 1, name: '...', price: 100 }, ...]
}
// Better: Store ID, fetch data
search: {
cartId: 'abc123'
}
// Bad: Sensitive data
search: {
token: 'secret-token'
}
// Better: Use session storage or state
// Keep sensitive data out of URLs

If you’re exploring URL state management:

  • React Query with URL state: Use URL params as query keys for automatic refetching
  • Next.js App Router: Similar patterns with useSearchParams, but less type-safe
  • React Router search params: Manual parsing, no built-in validation
  • Jotai/Recoil with atoms: Alternative state management, but not URL-based

The key insight: URL state isn’t about replacing all state management. It’s about recognizing which state belongs in the URL for better UX.

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