URL State 管理
URL 是最被低估的状态容器——它天然可分享、可刷新、可前进后退、对 SEO 友好。搜索关键词、筛选条件、分页、排序……这些状态放在 URL 中远比放在
useState中更好。
1. 为什么用 URL 管理状态
用 useState 存筛选条件有四个问题:
- 刷新丢失:用户 F5 刷新后筛选条件归零
- 无法分享:把链接发给同事,对方看到的是默认视图
- 后退不还原:点浏览器后退按钮,筛选条件不会恢复
- SEO 不可见:搜索引擎无法索引带筛选条件的页面
URL 天然解决这四个问题。每次筛选变化都反映在 ?query=xxx&page=2,浏览器自动帮你维护历史记录栈。
什么状态适合放 URL:
| 适合放 URL | 不适合放 URL |
|---|---|
搜索关键词 ?q=nextjs | 模态框开关状态 |
筛选条件 ?model=gpt-4o | 表单临时输入 |
分页 ?page=3 | 动画/拖拽状态 |
排序 ?sort=newest | 加载状态 |
Tab 选中 ?tab=billing | 鼠标悬停状态 |
日期范围 ?from=2024-01-01 | 是否正在提交 |
判断标准:这个状态是否应该在"分享链接"时被保留? 是 → URL,否 → useState/Zustand。
2. useSearchParams(Next.js 内置)
2.1 读取与更新
Next.js 提供 useSearchParams 读取 URL 参数。读取很简单,但更新参数需要手动拼接 URLSearchParams——这是它最大的痛点:
'use client'
import { useSearchParams, useRouter, usePathname } from 'next/navigation'
export function ModelFilter() {
const searchParams = useSearchParams()
const router = useRouter()
const pathname = usePathname()
const model = searchParams.get('model') ?? 'all'
function updateParam(key: string, value: string | null) {
const params = new URLSearchParams(searchParams.toString())
value ? params.set(key, value) : params.delete(key)
router.push(`${pathname}?${params.toString()}`)
}
return (
<select value={model} onChange={(e) =>
updateParam('model', e.target.value === 'all' ? null : e.target.value)
}>
<option value="all">全部模型</option>
<option value="gpt-4o">GPT-4o</option>
<option value="claude-3.5-sonnet">Claude 3.5</option>
</select>
)
}每次更新都要 new URLSearchParams → set/delete → router.push。如果有多个筛选条件,这段代码会不断重复。
2.2 Suspense 边界
useSearchParams 在 App Router 中会让组件变成动态渲染——需要用 Suspense 包裹,否则整个页面会变成客户端渲染:
import { Suspense } from 'react'
export default function ChatPage() {
return (
<div>
<Suspense fallback={<FilterSkeleton />}>
<ModelFilter />
</Suspense>
<ChatList />
</div>
)
}2.3 Server Component 中读取
在 Server Component 中不需要 useSearchParams——直接通过 searchParams prop 读取,可以直接用于数据库查询:
// app/(dashboard)/chat/page.tsx — Server Component
export default async function ChatPage({
searchParams,
}: {
searchParams: Promise<{ model?: string; sort?: string; page?: string }>
}) {
const { model, sort = 'newest', page = '1' } = await searchParams
const chats = await db.query.chats.findMany({
where: model ? eq(chats.model, model) : undefined,
orderBy: sort === 'newest' ? desc(chats.updatedAt) : asc(chats.updatedAt),
limit: 20,
offset: (Number(page) - 1) * 20,
})
return <ChatList chats={chats} />
}这是 URL 状态最强大的地方——URL 变化直接触发 Server Component 重新渲染,无需客户端状态管理、无需手动刷新数据。
2.4 useSearchParams 的痛点
- 全是
string类型:page是"3"不是3,需要手动转换 - 没有默认值:每次都要
?? 'newest' - 没有防抖:搜索框每个按键都触发 URL 更新和页面重渲染
- 没有批量更新:切换模型时想同时重置页码,需要手动处理
这正是 nuqs 要解决的。
3. nuqs — 类型安全的 URL 状态
安装:pnpm add nuqs
nuqs 的核心思想:把 URL 参数当 useState 用,但自动处理类型转换、默认值、防抖。API 设计和 useState 几乎一样,学习成本极低。
3.1 基础用法
'use client'
import { useQueryState, parseAsInteger, parseAsStringEnum } from 'nuqs'
export function ChatFilters() {
// 像 useState 一样用,但状态存在 URL 中
const [query, setQuery] = useQueryState('q', {
defaultValue: '',
throttleMs: 300, // 搜索框防抖 300ms
})
// 整数类型:自动 string → number 转换
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))
// 枚举类型:只允许指定值,其他值自动回退默认值
const [sort, setSort] = useQueryState(
'sort',
parseAsStringEnum(['newest', 'oldest', 'most-messages']).withDefault('newest'),
)
return (
<div className="flex gap-4">
<input
value={query}
onChange={(e) => setQuery(e.target.value || null)} // null 会移除 ?q= 参数
placeholder="搜索对话..."
/>
<select value={sort} onChange={(e) => setSort(e.target.value as any)}>
<option value="newest">最新</option>
<option value="oldest">最旧</option>
<option value="most-messages">最多消息</option>
</select>
<button onClick={() => setPage(page + 1)}>下一页(当前:{page})</button>
</div>
)
}
// URL: /chat?q=nextjs&sort=oldest&page=2
// 默认值时自动省略参数 → /chat(干净的 URL)setQuery(null) 会从 URL 中移除参数——这确保默认值时 URL 保持干净:/chat 而不是 /chat?q=&sort=newest&page=1。
3.2 批量更新
多个参数需要同时变化时(如切换筛选重置页码),用 useQueryStates 确保只触发一次 URL 更新:
import { useQueryStates, parseAsInteger, parseAsString } from 'nuqs'
export function ChatFilters() {
const [filters, setFilters] = useQueryStates({
q: parseAsString.withDefault(''),
model: parseAsString,
sort: parseAsString.withDefault('newest'),
page: parseAsInteger.withDefault(1),
})
function handleModelChange(model: string) {
// 切换模型时同时重置页码 — 一次 URL 更新
setFilters({ model: model || null, page: 1 })
}
function handleReset() {
// 重置所有筛选 — null 移除参数
setFilters({ q: null, model: null, sort: null, page: null })
}
return (
<div>
<input
value={filters.q}
onChange={(e) => setFilters({ q: e.target.value || null, page: 1 })}
/>
<select
value={filters.model ?? 'all'}
onChange={(e) => handleModelChange(e.target.value === 'all' ? '' : e.target.value)}
>
<option value="all">全部</option>
<option value="gpt-4o">GPT-4o</option>
</select>
<button onClick={handleReset}>重置筛选</button>
</div>
)
}如果用 useQueryState 分别管理 model 和 page,切换模型时两个 set 调用会触发两次 URL 更新、两次页面渲染。useQueryStates 把它们合并为一次。
3.3 配合 Server Component
nuqs 提供 createSearchParamsCache,让 Server Component 也能享受类型安全的参数解析。定义一次,Client 和 Server 共享同一套解析规则:
// app/(dashboard)/chat/search-params.ts — 定义一次
import { createSearchParamsCache, parseAsInteger, parseAsString } from 'nuqs/server'
export const searchParamsCache = createSearchParamsCache({
q: parseAsString.withDefault(''),
model: parseAsString,
sort: parseAsString.withDefault('newest'),
page: parseAsInteger.withDefault(1),
})// app/(dashboard)/chat/page.tsx — Server Component 使用
import { searchParamsCache } from './search-params'
export default async function ChatPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
// 类型安全的解析:page 自动变成 number,sort 有枚举检查
const { q, model, sort, page } = searchParamsCache.parse(await searchParams)
const chats = await getFilteredChats({ q, model, sort, page })
return (
<div>
<ChatFilters />
<ChatList chats={chats} />
<Pagination page={page} />
</div>
)
}好处:Client 端的 useQueryStates 和 Server 端的 searchParamsCache 使用相同的 parser 定义,确保两端对参数的解析行为完全一致。
4. useSearchParams vs nuqs
| 维度 | useSearchParams | nuqs |
|---|---|---|
| 类型安全 | ❌ 全是 string | ✅ number/enum/date |
| 默认值 | 手动处理 | 内置 .withDefault() |
| 防抖 | 手动实现 | 内置 throttleMs |
| 批量更新 | 手动拼接 | useQueryStates 一次更新 |
| Server 端解析 | searchParams prop | createSearchParamsCache |
| Suspense | 需要手动包裹 | 自动处理 |
| 默认值不入 URL | 手动判断 | 自动省略 |
推荐:只读取 1-2 个参数用 useSearchParams,有筛选/分页/排序需求用 nuqs。
5. 最佳实践
URL 参数命名:短、一致、符合惯例。
✅ /chat?q=nextjs&model=gpt-4o&sort=newest&page=2
❌ /chat?searchQuery=nextjs&selectedModel=gpt-4o&sortOrder=newest¤tPage=2
默认值不出现在 URL 中:/chat 表示第 1 页、最新排序;只有非默认值才出现:/chat?page=3&sort=oldest。nuqs 的 withDefault + null 自动处理。
router.push vs router.replace:分页通常用 push(用户可以后退到上一页),搜索输入通常用 replace(避免每个按键都产生历史记录)。nuqs 的 history 选项可以控制:
useQueryState('q', { defaultValue: '', history: 'replace' }) // 搜索用 replace
useQueryState('page', parseAsInteger.withDefault(1)) // 分页用 push(默认)本章小结
- URL 是状态容器:可分享、可刷新、可后退的状态放 URL
useSearchParams:Next.js 内置,适合简单读取,注意 Suspense 边界nuqs:类型安全 + 默认值 + 防抖 + 批量更新 + Server Component 支持- Server Component:URL 变化自动触发重渲染,无需客户端状态管理
- 默认值不入 URL:
/chat而非/chat?q=&sort=newest&page=1 - push vs replace:分页用 push,搜索用 replace
下一章讲 SaaS 应用状态架构设计。