关于 nuqs
nuqs 技能为使用 React 的编码 Agent 提供 URL 查询状态管理实践,涵盖类型安全解析、更新选项及 Next.js 服务端用法。
主要功能
- 01类型安全的查询参数解析
- 02数组、枚举与自定义解析器
- 03URL 历史与更新节流选项
- 04Next.js 服务端参数缓存
- 05测试适配器用法
适用场景
- 01在 URL 中保存搜索和筛选状态
- 02为分页与排序定义共享解析器
- 03在 Next.js 服务端读取查询参数
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ jbmanllr/ agent-skills --skill nuqs安装后在 Claude Code 中输入/nuqs确认已加载。
详细介绍
nuqs 最佳实践
适用于 React 的类型安全 URL 查询状态管理。像 useState 一样,但状态存储在 URL 中。
设置(必须先完成)
使用相应的适配器包装应用:
// Next.js App Router - app/layout.tsx
import { NuqsAdapter } from 'nuqs/adapters/next/app'
export default function RootLayout({ children }) {
return <NuqsAdapter>{children}</NuqsAdapter>
}
// Next.js Pages Router - pages/_app.tsx
import { NuqsAdapter } from 'nuqs/adapters/next/pages'
// React SPA (Vite/CRA)
import { NuqsAdapter } from 'nuqs/adapters/react'
// Remix - app/root.tsx
import { NuqsAdapter } from 'nuqs/adapters/remix'
// React Router v6/v7
import { NuqsAdapter } from 'nuqs/adapters/react-router'
全局选项
import { throttle } from 'nuqs'
<NuqsAdapter
defaultOptions={{
shallow: false, // notify server by default
scroll: true, // scroll to top on change
clearOnDefault: true, // remove param when equals default
limitUrlUpdates: throttle(250) // throttle URL updates
}}
>
{children}
</NuqsAdapter>
核心 API
单个参数
'use client'
import { useQueryState, parseAsInteger } from 'nuqs'
// String (default) - returns null | string
const [search, setSearch] = useQueryState('q')
// With parser + default (recommended)
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))
// Updates
setSearch('hello') // ?q=hello
setSearch(null) // removes param
setPage(p => p + 1) // functional update
await setPage(5) // returns Promise<URLSearchParams>
多个参数
import { useQueryStates, parseAsInteger, parseAsString } from 'nuqs'
const [filters, setFilters] = useQueryStates({
q: parseAsString.withDefault(''),
page: parseAsInteger.withDefault(1),
sort: parseAsString.withDefault('date')
})
// Partial updates
setFilters({ page: 1, sort: 'name' })
// Await batch update
const params = await setFilters({ page: 2 })
params.get('page') // '2'
内置解析器
| 解析器 | 类型 | URL 示例 |
|---|---|---|
parseAsString | string | ?q=hello |
parseAsInteger | number | ?page=1 |
parseAsFloat | number | ?price=9.99 |
parseAsHex | number | ?color=ff0000 |
parseAsBoolean | boolean | ?active=true |
parseAsIsoDateTime | Date | ?date=2024-01-15T10:30:00Z |
parseAsTimestamp | Date | ?t=1705312200000 |
parseAsArrayOf(parser) | T[] | ?tags=a,b,c |
parseAsArrayOf(parser, ';') | T[] | ?ids=1;2;3(自定义分隔符) |
parseAsJson<T>() | T | ?data={"key":"value"} |
parseAsStringEnum(values) | enum | ?status=active |
parseAsStringLiteral(arr) | literal | ?sort=asc |
parseAsNumberLiteral(arr) | literal | ?dice=6 |
枚举与字面量示例
// String enum
enum Status { Active = 'active', Inactive = 'inactive' }
const [status] = useQueryState('status',
parseAsStringEnum(Object.values(Status)).withDefault(Status.Active)
)
// String literal (type-safe)
const sortOptions = ['asc', 'desc'] as const
const [sort] = useQueryState('sort',
parseAsStringLiteral(sortOptions).withDefault('asc')
)
// Number literal
const diceSides = [1, 2, 3, 4, 5, 6] as const
const [dice] = useQueryState('dice',
parseAsNumberLiteral(diceSides).withDefault(1)
)
数组
// Default comma separator: ?tags=react,typescript,nuqs
const [tags, setTags] = useQueryState('tags',
parseAsArrayOf(parseAsString).withDefault([])
)
// Custom separator: ?ids=1;2;3
const [ids] = useQueryState('ids',
parseAsArrayOf(parseAsInteger, ';').withDefault([])
)
选项
useQueryState('key', parseAsString.withOptions({
history: 'push', // 'push' | 'replace' (default)
shallow: false, // true (default) = client only, false = notify server
scroll: false, // scroll to top on change
throttleMs: 500, // throttle URL updates (min 50ms)
clearOnDefault: true, // remove param when equals default (default: true)
startTransition, // React useTransition for loading states
}))
选项优先级:调用级别 > 解析器级别 > Hook 级别 > 全局适配器
// Parser-level options
const parser = parseAsString.withOptions({ shallow: false })
// Hook-level options
const [q, setQ] = useQueryState('q', parser, { history: 'push' })
// Call-level override (highest priority)
setQ('value', { shallow: true })
函数式更新与批处理
// Functional updates
setCount(c => c + 1)
setCount(c => c * 2) // Both batched in same tick
// Chained functional updates execute in order
function onClick() {
setCount(x => x + 1) // 0 → 1
setCount(x => x * 2) // 1 → 2
}
// Await updates
const search = await setFilters({ page: 2 })
search.get('page') // '2'
使用 useTransition 处理加载状态
'use client'
import { useTransition } from 'react'
import { useQueryState, parseAsString } from 'nuqs'
function Search({ results }) {
const [isLoading, startTransition] = useTransition()
const [query, setQuery] = useQueryState('q',
parseAsString.withOptions({
startTransition, // enables loading state
shallow: false // required for server updates
})
)
return (
<>
<input value={query ?? ''} onChange={e => setQuery(e.target.value)} />
{isLoading ? <Spinner /> : <Results data={results} />}
</>
)
}
自定义解析器
基本自定义解析器
// Simple date parser
const parseAsDate = {
parse: (value: string) => new Date(value),
serialize: (date: Date) => date.toISOString().split('T')[0]
}
const [date, setDate] = useQueryState('date', parseAsDate)
使用 createParser(适用于引用类型)
对于非原始类型,请提供 eq 函数,以便 clearOnDefault 生效:
import { createParser, parseAsStringLiteral } from 'nuqs'
// Date with equality check
const parseAsDate = createParser({
parse: (value: string) => new Date(value.slice(0, 10)),
serialize: (date: Date) => date.toISOString().slice(0, 10),
eq: (a: Date, b: Date) => a.getTime() === b.getTime()
})
// Complex type (e.g., TanStack Table sort state)
// URL: ?sort=name:asc → { id: 'name', desc: false }
const parseAsSort = createParser({
parse(query) {
const [id = '', dir = ''] = query.split(':')
return { id, desc: dir === 'desc' }
},
serialize(value) {
return `${value.id}:${value.desc ? 'desc' : 'asc'}`
},
eq(a, b) {
return a.id === b.id && a.desc === b.desc
}
})
服务器组件(Next.js)
// lib/searchParams.ts
import { createSearchParamsCache, parseAsInteger, parseAsString } from 'nuqs/server'
export const searchParamsCache = createSearchParamsCache({
q: parseAsString.withDefault(''),
page: parseAsInteger.withDefault(1)
})
// app/search/page.tsx (Server Component)
import { searchParamsCache } from '@/lib/searchParams'
import type { SearchParams } from 'nuqs/server'
type Props = { searchParams: Promise<SearchParams> }
export default async function Page({ searchParams }: Props) {
// ⚠️ Must call parse() - don't forget!
const { q, page } = await searchParamsCache.parse(searchParams)
return <Results query={q} page={page} />
}
// Nested server component - no props needed
function NestedComponent() {
const page = searchParamsCache.get('page') // type-safe!
return <span>Page {page}</span>
}
可复用模式
共享解析器定义
// lib/parsers.ts
export const paginationParsers = {
page: parseAsInteger.withDefault(1),
limit: parseAsInteger.withDefault(20),
sort: parseAsString.withDefault('createdAt'),
order: parseAsStringLiteral(['asc', 'desc'] as const).withDefault('desc')
}
// Component
const [pagination, setPagination] = useQueryStates(paginationParsers)
URL 键映射
const [coords, setCoords] = useQueryStates(
{
latitude: parseAsFloat.withDefault(0),
longitude: parseAsFloat.withDefault(0)
},
{
urlKeys: { latitude: 'lat', longitude: 'lng' }
}
)
// URL: ?lat=45.5&lng=-122.6
// Code: coords.latitude, coords.longitude
自定义 Hook
// hooks/useFilters.ts
export function useFilters() {
return useQueryStates({
search: parseAsString.withDefault(''),
category: parseAsString,
minPrice: parseAsFloat,
maxPrice: parseAsFloat,
inStock: parseAsBoolean.withDefault(false)
})
}
// Component
const [filters, setFilters] = useFilters()
测试
import { withNuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
it('updates URL on click', async () => {
const user = userEvent.setup()
const onUrlUpdate = vi.fn<[UrlUpdateEvent]>()
render(<CounterButton />, {
wrapper: withNuqsTestingAdapter({
searchParams: '?count=1',
onUrlUpdate
})
})
await user.click(screen.getByRole('button'))
expect(screen.getByRole('button')).toHaveTextContent('count is 2')
expect(onUrlUpdate).toHaveBeenCalledOnce()
const event = onUrlUpdate.mock.calls[0]![0]!
expect(event.queryString).toBe('?count=2')
expect(event.searchParams.get('count')).toBe('2')
expect(event.options.history).toBe('push')
})
需要避免的关键错误
1. 缺少适配器
// ❌ Error: nuqs requires an adapter
useQueryState('q')
// ✅ Wrap app in NuqsAdapter first (see Setup section)
2. 框架适配器错误
// ❌ Using app router adapter in pages router
import { NuqsAdapter } from 'nuqs/adapters/next/app' // Wrong!
// ✅ Match adapter to your router
import { NuqsAdapter } from 'nuqs/adapters/next/pages'
3. 缺少 Suspense(Next.js App Router)
// ❌ Hydration error
export default function Page() {
const [q] = useQueryState('q')
return <div>{q}</div>
}
// ✅ Wrap client components in Suspense
export default function Page() {
return (
<Suspense fallback={<Loading />}>
<SearchClient />
</Suspense>
)
}
4. 同一个键使用不同的解析器
// ❌ Conflicts - last update wins with wrong type
const [intVal] = useQueryState('foo', parseAsInteger)
const [floatVal] = useQueryState('foo', parseAsFloat)
// ✅ One parser per key, share via custom hook
function useFoo() {
const [val, setVal] = useQueryState('foo', parseAsFloat)
return { float: val, int: Math.floor(val ?? 0), setVal }
}
5. 忘记在服务器上解析
// ❌ Returns cache object, not values
const values = searchParamsCache // Wrong!
// ✅ Call parse() with searchParams prop
const values = await searchParamsCache.parse(searchParams)
6. 在服务器组件中使用客户端 Hook
// ❌ useQueryState only works in client components
export default function Page() { // Server component
const [q] = useQueryState('q') // Error!
}
// ✅ Use createSearchParamsCache for server, useQueryState for client
7. 未处理没有默认值时的 null
// ❌ Tedious null handling
const [count, setCount] = useQueryState('count', parseAsInteger)
setCount(c => (c ?? 0) + 1) // Must handle null every time
// ✅ Use withDefault
const [count, setCount] = useQueryState('count', parseAsInteger.withDefault(0))
setCount(c => c + 1) // Always a number
8. 有损序列化
// ❌ Loses precision on reload
const geoParser = {
parse: parseFloat,
serialize: v => v.toFixed(2) // 1.23456 → "1.23" → 1.23
}
// ✅ Preserve precision or accept the tradeoff knowingly
const geoParser = {
parse: parseFloat,
serialize: v => v.toString()
}
9. 引用类型缺少 eq
// ❌ clearOnDefault won't work correctly
const dateParser = {
parse: (v) => new Date(v),
serialize: (d) => d.toISOString()
}
// ✅ Provide eq function for reference types
const dateParser = createParser({
parse: (v) => new Date(v),
serialize: (d) => d.toISOString(),
eq: (a, b) => a.getTime() === b.getTime()
})
快速参考
| 任务 | 解决方案 |
|---|---|
| 单个参数 | useQueryState('key', parser.withDefault(val)) |
| 多个参数 | useQueryStates({ key: parser }) |
| 服务器访问 | createSearchParamsCache + .parse() |
| 通知服务器 | { shallow: false } |
| 历史记录条目 | { history: 'push' } |
| 加载状态 | useTransition + { startTransition } |
| 短 URL 键 | urlKeys: { longName: 'short' } |
| 数组参数 | parseAsArrayOf(parser) 或 parseAsArrayOf(parser, ';') |
| 枚举/字面量 | parseAsStringLiteral(['a', 'b'] as const) |
| 自定义类型 | createParser({ parse, serialize, eq }) |
| 测试组件 | withNuqsTestingAdapter({ searchParams: '?...' }) |
评论
支持 Markdown