URL State 管理

URL 是最被低估的状态容器——它天然可分享、可刷新、可前进后退、对 SEO 友好。搜索关键词、筛选条件、分页、排序……这些状态放在 URL 中远比放在 useState 中更好。

1. 为什么用 URL 管理状态

useState 存筛选条件有四个问题:

  1. 刷新丢失:用户 F5 刷新后筛选条件归零
  2. 无法分享:把链接发给同事,对方看到的是默认视图
  3. 后退不还原:点浏览器后退按钮,筛选条件不会恢复
  4. 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 URLSearchParamsset/deleterouter.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 分别管理 modelpage,切换模型时两个 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

维度useSearchParamsnuqs
类型安全❌ 全是 string✅ number/enum/date
默认值手动处理内置 .withDefault()
防抖手动实现内置 throttleMs
批量更新手动拼接useQueryStates 一次更新
Server 端解析searchParams propcreateSearchParamsCache
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&currentPage=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 应用状态架构设计。