面向 React 与 Next.js 的类型化 URL 状态—— 用法像 useState

useUrlState 是将自身写入查询字符串的 React 状态。对象、数组和日期保持其类型,每个状态都是一个可分享的链接,重新加载后仍然存在,后退按钮也正常工作——无需 provider,无需 Suspense 边界,也无需样板代码。

  • ~2 KB gzip 压缩
  • 零依赖
  • TypeScript 优先
  • Next.js / react-router / Remix / Astro
  • MIT
npm i state-in-url

Next.js App Router 中的 URL 状态管理

state-in-url 在 Next.js 14、15 和 16 上把类型化状态保存在查询字符串中:每个功能一个 useUrlState hook,没有适配器,没有 provider,也没有 Suspense 边界。本页只讲 App Router 特有的部分——Server Components、预渲染、布局和历史记录。

在线演示位于 首页,运行在 Next.js 16 上。

从服务端页面转发 searchParams

Server Component 页面会收到 searchParams——从 Next.js 15 起它是一个 Promise。await 它,再把对象传给客户端组件,由客户端组件交给 hook。这样首次服务端渲染显示的就是 URL 里的值而不是默认值,没有闪烁,也没有 hydration 警告。

page.tsx
// app/jobs/page.tsx  (Server Component)
import { JobsList } from './JobsList';

export default async function Page({
  searchParams,
}: {
  searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
  // A Promise since Next.js 15; a plain object in 14
  return <JobsList searchParams={await searchParams} />;
}
JobsList.tsx
// app/jobs/JobsList.tsx
'use client';
import { useUrlState } from 'state-in-url/next';

// Outside the component: sharing is keyed by object identity
const JOBS_STATE = { q: '', page: 1, remote: false, tags: [] as string[] };

export function JobsList({ searchParams }: { searchParams: object }) {
  const { urlState, setUrl } = useUrlState(JOBS_STATE, { searchParams });

  return (
    <input
      value={urlState.q}
      onChange={(ev) => setUrl({ q: ev.target.value, page: 1 })}
    />
  );
}

无需 Suspense 边界,预渲染保留

该 hook 从不调用 useSearchParams,因此使用它的组件无需包裹在 Suspense 中,也不会让页面退出预渲染——PPR 和 cacheComponents 也在内。它直接读取 URL 并跟踪之后的每一次变更,包括来自一段对它一无所知的代码的 history.pushState 预渲染的页面渲染出的仍是默认值,因为构建时没有查询字符串——如果分享链接必须在首屏就正确,就把该路由改为动态渲染。

布局:从请求头解码查询字符串

服务端布局永远收不到 searchParams。在 proxy.ts 中把查询字符串复制进一个请求头(middleware.ts 作为已弃用的别名仍然可用),再在布局里用 decodeState 和同一个默认状态对象解码——结果的类型与客户端的 urlState 完全一致。

proxy.ts
// proxy.ts  (middleware.ts before Next.js 16)
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';

export function proxy(request: NextRequest) {
  const sp = (request.url.includes('_next') ? '' : request.url).split('?')[1] ?? '';
  const headers = new Headers(request.headers);
  headers.set('searchParams', sp);
  return NextResponse.next({ request: { headers } });
}
layout.tsx
// app/jobs/layout.tsx  (Server Component)
import { headers } from 'next/headers';
import { decodeState } from 'state-in-url/encodeState';
import { JOBS_STATE } from './jobsState';

export default async function Layout({ children }: { children: React.ReactNode }) {
  const sp = (await headers()).get('searchParams') ?? '';
  const initial = decodeState(sp, JOBS_STATE); // typed like urlState

  return <>{/* use `initial` */}{children}</>;
}

历史记录、浅层更新与 scroll

setUrl 默认替换当前历史记录条目,所以打字不会堆出一串条目;传入 replace: false 则会推入新条目。更新通过 History API 完成——没有服务端往返,每次按键也不会发出 _rsc 请求。当服务端需要在每次变更时重新渲染,传入 useHistory: false 改走 Next.js 路由器。scroll 默认为 false。

快速输入:先渲染,稍后再写 URL

对于文本框和滑块,每次变化时用 setState 更新,在失焦或防抖之后调用不带参数的 setUrl()。组件立即重新渲染;URL 只写一次,并基于内容做差异比较,所以重复调用也是安全的。

useJobsState — usage
const { urlState, setState, setUrl } = useJobsState();

// Render now, write the URL once the field is left
<input
  value={urlState.q}
  onChange={(ev) => setState({ q: ev.target.value })}
  onBlur={() => setUrl()}
/>

setUrl({ page: 2 });                     // replaces the history entry (default)
setUrl({ page: 2 }, { replace: false }); // pushes a new one — Back returns to page 1
setUrl({ page: 2 }, { scroll: true });   // scroll to top, off by default

阅读完整对比——同一个功能在两个库里的写法,以及如何迁移

Next.js URL 状态——常见问题

如何在 Next.js App Router 中把状态保存在 URL 里?
在组件外定义一个默认状态对象,把 state-in-url/next 的 useUrlState 包成一个小 hook,然后在任意客户端组件里调用它。urlState 是带类型的当前值,setUrl 把一个 partial 写进查询字符串。把页面的 searchParams prop 传进去,服务端渲染就已经是正确的。
useSearchParams 需要 Suspense 边界吗?state-in-url 呢?
Next.js 的 useSearchParams 会让静态渲染的路由在最近的 Suspense 边界以内改为客户端渲染,没有边界时构建会失败。state-in-url 从不调用它:服务端读 searchParams,客户端读 window.location,所以不需要边界,预渲染(包括 PPR)也得以保留。
如何在 Server Component 中读取 URL 状态?
页面通过 searchParams prop 拿到它——await 之后,要么转发给客户端 hook,要么在服务端用 decodeState 和同一个默认对象解码。布局收不到 searchParams;在 proxy.ts 里设置一个请求头暴露查询字符串,然后解码那个请求头。
更新 URL 会让页面在服务端重新渲染吗?
默认不会。setUrl 通过 History API 更新,不会请求任何东西,也不会发出 _rsc 请求。当服务端需要看到新状态时——比如在 Server Component 里重新获取列表——传入 useHistory: false,更新就会走 Next.js 路由器,路由随之重新渲染。
在 Next.js 上,state-in-url 是 nuqs 的替代品吗?
是。两者都把类型化状态放进查询字符串;state-in-url 接收一个对象,嵌套值和日期都保留,不需要适配器组件,也不需要逐键解析器,并且从不触碰 useSearchParams。若希望每个值都是一条人可读的查询参数,nuqs 更合适。详见完整对比。
支持哪些 Next.js 版本?
App Router 上的 Next.js 14、15 和 16,包括 15 引入的异步 searchParams 以及 16 中配合 PPR 的 cacheComponents。其他环境可以用与框架无关的 encodeState 和 decodeState 辅助函数,搭配自己选择的路由器。