Skip to content

Commit 55a8664

Browse files
committed
blog post
1 parent a1bfd3e commit 55a8664

File tree

2 files changed

+184
-0
lines changed

2 files changed

+184
-0
lines changed
85.7 KB
Loading

src/blog/search-params-are-state.md

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
---
2+
title: Search Params Are State
3+
published: 2025-06-03
4+
authors:
5+
- Tanner Linsley
6+
---
7+
8+
![Search Params Are State Header](/blog-assets/search-params-are-state/search-params-are-state-header.jpg)
9+
10+
## Search Params Are State — Treat Them That Way
11+
12+
Search params have been historically treated like second-class state. They're global, serializable, and shareable — but in most apps, they’re still hacked together with string parsing, loose conventions, and brittle utils.
13+
14+
Even something simple, like validating a `sort` param, quickly turns verbose:
15+
16+
```ts
17+
const schema = z.object({
18+
sort: z.enum(['asc', 'desc']),
19+
})
20+
21+
const raw = Object.fromEntries(new URLSearchParams(location.href))
22+
const result = schema.safeParse(raw)
23+
24+
if (!result.success) {
25+
// fallback, redirect, or show error
26+
}
27+
```
28+
29+
This works, but it’s manual and repetitive. There’s no inference, no connection to the route itself, and it falls apart the moment you want to add more types, defaults, transformations, or structure.
30+
31+
Even worse, `URLSearchParams` is string-only. It doesn’t support nested JSON, arrays (beyond naive comma-splitting), or type coercion. So unless your state is flat and simple, you’re going to hit walls fast.
32+
33+
That’s why we’re starting to see a rise in tools and proposals — things like Nuqs, Next.js RFCs, and userland patterns — aimed at making search params more type-safe and ergonomic. Most of these focus on improving _reading_ from the URL.
34+
35+
But almost none of them solve the deeper, harder problem: **writing** search params, safely and atomically, with full awareness of routing context.
36+
37+
---
38+
39+
### Writing Search Params Is Where It Falls Apart
40+
41+
It’s one thing to read from the URL. It’s another to construct a valid, intentional URL from code.
42+
43+
The moment you try to do this:
44+
45+
```tsx
46+
<Link to="/dashboards/overview" search={{ sort: 'asc' }} />
47+
```
48+
49+
You realize you have no idea what search params are valid for this route, or if you’re formatting them correctly. Even with a helper to stringify them, nothing is enforcing contracts between the caller and the route. There’s no type inference, no validation, and no guardrails.
50+
51+
This is where **constraint becomes a feature**.
52+
53+
Without explicitly declaring search param schemas in the route itself, you’re stuck guessing. You might validate in one place, but there’s nothing stopping another component from navigating with invalid, partial, or conflicting state.
54+
55+
Constraint is what makes coordination possible. It’s what allows **non-local callers** to participate safely.
56+
57+
---
58+
59+
### Local Abstractions Can Help — But They Don’t Coordinate
60+
61+
Tools like **Nuqs** are a great example of how local abstractions can improve the _ergonomics_ of search param handling. You get Zod-powered parsing, type inference, even writable APIs — all scoped to a specific component or hook.
62+
63+
They make it easier to read and write search params **in isolation** — and that’s valuable.
64+
65+
But they don’t solve the broader issue of **coordination**. You still end up with duplicated schemas, disjointed expectations, and no way to enforce consistency between routes or components. Defaults can conflict. Types can drift. And when routes evolve, nothing guarantees all the callers update with them.
66+
67+
That’s the real fragmentation problem — and fixing it requires bringing search param schemas into the routing layer itself.
68+
69+
---
70+
71+
### How TanStack Router Solves It
72+
73+
TanStack Router solves this holistically.
74+
75+
Instead of spreading schema logic across your app, you define it **inside the route itself**:
76+
77+
```ts
78+
export const Route = createFileRoute('/dashboards/overview')({
79+
validateSearch: z.object({
80+
sort: z.enum(['asc', 'desc']),
81+
filter: z.string().optional(),
82+
}),
83+
})
84+
```
85+
86+
This schema becomes the single source of truth. You get full inference, validation, and autocomplete everywhere:
87+
88+
```tsx
89+
<Link
90+
to="/dashboards/overview"
91+
search={{ sort: 'asc' }} // fully typed, fully validated
92+
/>
93+
```
94+
95+
Want to update just part of the search state? No problem:
96+
97+
```ts
98+
navigate({
99+
search: (prev) => ({ ...prev, page: prev.page + 1 }),
100+
})
101+
```
102+
103+
It’s reducer-style, transactional, and integrates directly with the router’s reactivity model. Components only re-render when the specific search param they use changes — not every time the URL mutates.
104+
105+
---
106+
107+
### How TanStack Router Prevents Schema Fragmentation
108+
109+
When your search param logic lives in userland — scattered across hooks, utils, and helpers — it’s only a matter of time before you end up with **conflicting schemas**.
110+
111+
Maybe one component expects \`sort: 'asc' | 'desc'\`. Another adds a \`filter\`. A third assumes \`sort: 'desc'\` by default. None of them share a source of truth.
112+
113+
This leads to:
114+
115+
- Inconsistent defaults
116+
- Colliding formats
117+
- Navigation that sets values others can’t parse
118+
- Broken deep linking and bugs you can’t trace
119+
120+
TanStack Router prevents this by tying schemas directly to your route definitions — **hierarchically**.
121+
122+
Parent routes can define shared search param validation. Child routes inherit that context, add to it, or extend it in type-safe ways. This makes it _impossible_ to accidentally create overlapping, incompatible schemas in different parts of your app.
123+
124+
---
125+
126+
### Example: Safe Hierarchical Search Param Validation
127+
128+
Here’s how this works in practice:
129+
130+
```ts
131+
// routes/dashboard.tsx
132+
export const Route = createFileRoute('/dashboard')({
133+
validateSearch: z.object({
134+
sort: z.enum(['asc', 'desc']).default('asc'),
135+
}),
136+
})
137+
```
138+
139+
Then a child route can extend the schema safely:
140+
141+
```ts
142+
// routes/dashboard/$dashboardId.tsx
143+
export const Route = createFileRoute('/dashboard/$dashboardId')({
144+
validateSearch: z.object({
145+
filter: z.string().optional(),
146+
// ✅ \`sort\` is inherited automatically from the parent
147+
}),
148+
})
149+
```
150+
151+
When you match \`/dashboard/123?sort=desc&filter=active\`, the parent validates \`sort\`, the child validates \`filter\`, and everything works together seamlessly.
152+
153+
Try to redefine or omit the required parent param in the child route? Type error.
154+
155+
```ts
156+
// ❌ Type error: missing required \`sort\` param from parent
157+
validateSearch: z.object({
158+
filter: z.string().optional(),
159+
})
160+
```
161+
162+
This kind of enforcement makes nested routes composable _and_ safe — a rare combo.
163+
164+
---
165+
166+
### Built-In Discipline
167+
168+
The magic here is that you don’t need to teach your team to follow conventions. The route _owns_ the schema. Everyone just uses it. There’s no duplication. No drift. No silent bugs. No guessing.
169+
170+
When you bring validation, typing, and ownership into the router itself, you stop treating URLs like strings and start treating them like real state — because that’s what they are.
171+
172+
---
173+
174+
### Search Params Are State
175+
176+
Most routing systems treat search params like an afterthought. Something you _can_ read, maybe parse, maybe stringify, but rarely something you can actually **trust**.
177+
178+
TanStack Router flips that on its head. It makes search params a core part of the routing contract — validated, inferable, writable, and reactive.
179+
180+
Because if you’re not treating search params like state, you’re going to keep leaking it, breaking it, and working around it.
181+
182+
Better to treat it right from the start.
183+
184+
If you're intrigued by the possibilities of treating search params as first-class state, we invite you to try out [TanStack Router](https://tanstack.com/router). Experience the power of validated, inferable, and reactive search params in your routing logic.

0 commit comments

Comments
 (0)