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:
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:
import { createFileRoute } from '@tanstack/react-router'import { z } from 'zod'
// Define schema for type safetyconst 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<typeof search>) => { 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: "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):
User: Sets filters, copies URL, sends to colleagueColleague: Opens URL, sees exact same filtered viewUser: Refreshes page, filters still thereUser: Bookmarks page, returns next week, state restoredSupport ticket: "Why didn't we do this earlier?"The URLs became meaningful:
Before: /productsAfter: /products?category=electronics&sort=price&page=3
Before: /usersAfter: /users?status=active&department=engineering&sort=nameEach 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:
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.
Navigation with State Preservation
One of my favorite features: updating one filter without losing others:
// 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:
// This is what I used to doconst [filters, setFilters] = useState(initialFilters)
// Filter by categorysetFilters({ ...filters, category: 'electronics' })// Wait, did I preserve sort? Yes.// Did I preserve page? Oops, forgot to reset page to 1
// Next pagesetFilters({ ...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 unmaintainableWith 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:
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:
// Easy to forget adding 'category' to queryKeyconst { 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:
// This is badconst [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:
// This works but has no validationvalidateSearch: (search) => ({ page: Number(search?.page || 1), sort: search?.sort || 'name'})
// User types ?page=abc// page becomes NaN// Everything breaksSolution: Use Zod for 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 issuesMistake 3: Over-engineering simple views
Not every piece of UI needs 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 stateMigration Strategy
If you’re using useState for view configuration, here’s how to migrate:
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 buttonsPerformance Considerations
I worried about URL parsing performance. Turns out, it’s negligible:
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 improvementThe 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
// Bad: Too large for URLsearch: { products: [{ id: 1, name: '...', price: 100 }, ...]}
// Better: Store ID, fetch datasearch: { cartId: 'abc123'}
// Bad: Sensitive datasearch: { token: 'secret-token'}
// Better: Use session storage or state// Keep sensitive data out of URLsRelated Knowledge
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:
- 👨💻 TanStack Router Reddit Discussion
- 👨💻 TanStack Router Documentation
- 👨💻 Type-Safe Routing Patterns
- 👨💻 URL as State Source
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments