state-in-url
Version:
Store state in URL as in object, types and structure are preserved, with TS validation. Same API as React.useState, wthout any hasssle or boilerplate. Next.js@14-15, react-router@6-7, and remix@2.
779 lines (592 loc) • 23.5 kB
Markdown
# State in url
[English](./README.md) | [한국어](./README.KO.md) | 简体中文
<div align="center">
<img src="/packages/example-nextjs14/public/Logo_symbol.png" alt="state-in-url logo" width="120px" />
# State in url
</div>
<div align="center">
</div>
<div align="center">
[](https://www.npmjs.com/package/state-in-url)

[](https://app.codacy.com/gh/asmyshlyaev177/state-in-url/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)

[](https://app.codacy.com/gh/asmyshlyaev177/state-in-url/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_coverage)
[](https://github.com/asmyshlyaev177/state-in-url/)
[]([https://github.com/semantic-release/semantic-release](https://github.com/asmyshlyaev177/state-in-url))
[](https://scorecard.dev/viewer/?uri=github.com/asmyshlyaev177/state-in-url)
[](https://www.bestpractices.dev/projects/9679)
[](https://github.com/asmyshlyaev177/state-in-url/blob/master/LICENSE)
</div>
<div align="center">
<h4 align="center">如果您发现了 bug 或想请求新功能,请随时提交 issue</h4>

# 演示
<a href="https://state-in-url.dev" target="_blank">演示</a> |
<a href="https://state-in-url.netlify.app/" target="_blank">镜像链接</a>
<a href="https://github.com/asmyshlyaev177/state-in-url/blob/master/Limits.md" target="_blank">URI 大小限制,<b>最多 12KB</b> 是安全的</a>
<hr />
添加一个 <a href="#"><img src="https://raw.githubusercontent.com/acervenky/animated-github-badges/master/assets/starbadge.gif" width="25" height="25"></a> 并<a href="https://github.com/asmyshlyaev177" target="_blank">关注我</a>以支持该项目!
非常欢迎您在[讨论区](https://github.com/asmyshlyaev177/state-in-url/discussions/1)分享您的反馈/意见
如果对您有用,请分享。
[X.com](https://twitter.com/intent/tweet?&url=https://github.com/asmyshlyaev177/state-in-url)
[LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https://github.com/asmyshlyaev177/state-in-url)
[FB](https://www.facebook.com/sharer.php?u=https://github.com/asmyshlyaev177/state-in-url)
[VK](http://vk.com/share.php?url=https://github.com/asmyshlyaev177/state-in-url)
<hr />
</div>
[直接看代码!](#useurlstate)
## 为什么使用 `state-in-url`?
在查询参数中存储任何用户状态;想象一下在浏览器 URL 中使用 JSON。所有这些都保持数据的类型和结构,例如数字将被解码为数字而不是字符串,日期作为日期,支持对象和数组。
简单明了、快速,并提供静态 TypeScript 验证。轻松实现深层链接(又名 URL 同步)。
包含用于 Next.js 和 react-router 的 `useUrlState` hook,以及用于任何其他 JS 框架的辅助函数。
由于现代浏览器支持巨大的 URL,用户也不关心查询字符串(这是一个全选和复制/粘贴的工作流程)。
是时候使用查询字符串进行状态管理了,正如它最初的用途。
这个库为您完成所有繁琐的工作。
这个库是 NUQS 的一个很好的替代品。
### 使用场景
- 在 URL 中存储未保存的用户表单或页面筛选器
- 将 URL 与 React 状态同步
- 只需在不相关的客户端组件之间同步数据,而无需触及 URI
- 带有应用程序状态的可共享 URL(深层链接、URL 状态同步)
- 轻松实现跨页面重新加载的状态持久化
### 特性
- 🧩 **简单**: 无需 providers、reducers、样板代码或新概念,API 类似于 `React.useState`
- 📘 **TypeScript 验证/自动完成**: 状态只是一个对象,根据 TypeScript 定义在 IDE/测试中自动静态验证
- ✨ **复杂数据**: 嵌套对象、日期和数组,与 JSON 工作方式相同,但在 URL 中
- ☂ **默认值**: 如果参数不在 url 中,为您提供默认值
- ⌨ **有序**: 所有可能的值在开始时定义,防止您获取不存在的键
- **兼容**: 将保持第三方查询参数不变
- **灵活**: 可以在同一页面上使用多个状态对象,只需使用不同的键
- **快速**: 最少的重新渲染,大约[1ms](https://github.com/asmyshlyaev177/state-in-url/blob/87c8c7c995c5cd7d9e7aa039c30bfe64b24abe4b/packages/urlstate/encoder/encoder.test.ts#L185)来编码和解码大对象
- **服务器端渲染**: 可以在服务器组件中使用,支持 Next.js 14 和 15
- **轻量级**: 零依赖,库小于 2KB
- **开发体验**: 良好的开发者体验、文档、JSDoc 注释和示例
- **框架灵活性**: 为 `Next.js` 和 `react-router` 提供 hooks,以及用于其他框架或纯 JS 的辅助函数
- **经过充分测试**: [Chrome/Firefox/Safari 的单元测试和 Playwright 测试](https://github.com/asmyshlyaev177/state-in-url/actions/workflows/tests.yml)
- **宽松许可证**: MIT
## 目录
- [State in url](#state-in-url)
- [State in url](#state-in-url-1)
- [演示](#演示)
- [为什么使用 `state-in-url`?](#为什么使用-state-in-url)
- [使用场景](#使用场景)
- [特性](#特性)
- [目录](#目录)
- [安装](#安装)
- [1. 安装包](#1-安装包)
- [2. 编辑 tsconfig.json](#2-编辑-tsconfigjson)
- [useUrlState](#useurlstate)
- [用于 Next.js 的 useUrlState hook](#用于-nextjs-的-useurlstate-hook)
- [使用示例](#使用示例)
- [基础用法](#基础用法)
- [使用服务器端渲染](#使用服务器端渲染)
- [在 `layout` 组件中使用 hook](#在-layout-组件中使用-hook)
- [使用任意状态形状(不推荐)](#使用任意状态形状不推荐)
- [用于 Remix.js 的 useUrlState hook](#用于-remixjs-的-useurlstate-hook)
- [示例](#示例)
- [用于 React-Router 的 useUrlState hook](#用于-react-router-的-useurlstate-hook)
- [示例](#示例-1)
- [实用技巧](#实用技巧)
- [自定义 hook 以便捷地处理状态切片](#自定义-hook-以便捷地处理状态切片)
- [使用复杂状态形状](#使用复杂状态形状)
- [仅更新状态并手动同步到 URL](#仅更新状态并手动同步到-url)
- [其他 hooks 和辅助函数](#其他-hooks-和辅助函数)
- [`useUrlStateBase` hook 用于其他路由器](#useurlstatebase-hook-用于其他路由器)
- [`useSharedState` hook 用于 React.js](#usesharedstate-hook-用于-reactjs)
- [`useUrlEncode` hook 用于 React.js](#useurlencode-hook-用于-reactjs)
- [`encodeState` 和 `decodeState` 辅助函数](#encodestate-和-decodestate-辅助函数)
- [`encode` 和 `decode` 辅助函数](#encode-和-decode-辅助函数)
- [最佳实践](#最佳实践)
- [注意事项](#注意事项)
- [其他](#其他)
- [贡献和/或本地运行](#贡献和或本地运行)
- [路线图](#路线图)
- [联系和支持](#联系和支持)
- [更新日志](#更新日志)
- [提及](#提及)
- [许可证](#许可证)
- [个人网站](#个人网站)
- [灵感来源](#灵感来源)
## 安装
### 1. 安装包
```sh
# npm
npm install --save state-in-url
# yarn
yarn add state-in-url
# pnpm
pnpm add state-in-url
```
### 2. 编辑 tsconfig.json
在 `tsconfig.json` 的 `compilerOptions` 中设置 `"moduleResolution": "Bundler"`,或 `"moduleResolution": "Node16"`,或 `"moduleResolution": "NodeNext"`。
可能需要设置 `"module": "ES2022"`,或 `"module": "ESNext"`
## useUrlState
主 hook,接受初始状态作为参数,返回状态对象、更新 url 的回调以及仅更新状态的回调。
使用相同 `state` 对象的所有组件都会自动同步。
### 用于 Next.js 的 useUrlState hook
[完整 API 文档](packages/urlstate/next/useUrlState)
[React-Router 示例](#用于-react-router-的-useurlstate-hook)
#### 使用示例
##### 基础用法
1. 使用默认值定义状态形状
```typescript
// userState.ts
// 只有与默认值不同的参数才会进入 url。
export const userState: UserState = { name: '', age: 0 }
// 使用 `Type` 而不是 `Interface`!
type UserState = { name: string, age: number }
```
2. 导入并使用
```typescript
'use client'
import { useUrlState } from 'state-in-url/next';
import { userState } from './userState';
function MyComponent() {
// 可以传递 `replace` 参数,它控制 `setUrl` 是否使用 `router.push` 或 `router.replace`,默认 replace=true
// 可以从服务器组件传递 `searchParams`,如果需要在服务器组件中获取数据,则传递 `useHistory: false`
const { urlState, setUrl, setState } = useUrlState(userState);
return (
<div>
// 如果 url 为空,urlState.name 将返回 `userState` 的默认值
<input value={urlState.name}
// 与 React.useState 相同的 API,例如 setUrl(currVal => currVal + 1)
onChange={(ev) => setUrl({ name: ev.target.value }) }
/>
<input value={urlState.age}
onChange={(ev) => setUrl({ age: +ev.target.value }) }
/>
<input value={urlState.name}
onChange={(ev) => { setState(curr => ({ ...curr, name: ev.target.value })) }}
// 可以立即更新状态,但根据需要将更改同步到 url
onBlur={() => setUrl()}
/>
<button onClick={() => setUrl((_, initial) => initial)}>
重置
</button>
</div>
)
}
```
##### 使用服务器端渲染
<details>
<Summary>示例</Summary>
```typescript
export default async function Home({ searchParams }: { searchParams: object }) {
return (
<Form searchParams={searchParams} />
)
}
// Form.tsx
'use client'
import React from 'react';
import { useUrlState } from 'state-in-url/next';
import { form } from './form';
const Form = ({ searchParams }: { searchParams: object }) => {
const { urlState, setState, setUrl } = useUrlState(form, { searchParams });
}
```
</details>
##### 在 `layout` 组件中使用 hook
<details>
<Summary>示例</Summary>
这是一个棘手的部分,因为带有 app router 的 nextjs 不允许从服务器端访问 searchParams。有一个使用 middleware 的解决方法,但它不够完美,可能在 nextjs 更新后停止工作。
```typescript
// 添加到适当的 `layout.tsx`
export const runtime = 'edge';
// middleware.ts
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
const url = request.url?.includes('_next') ? null : request.url;
const sp = url?.split?.('?')?.[1] || '';
const response = NextResponse.next();
if (url !== null) {
response.headers.set('searchParams', sp);
}
return response;
}
// 目标布局组件
import { headers } from 'next/headers';
import { decodeState } from 'state-in-url/encodeState';
export default async function Layout({
children,
}: {
children: React.ReactNode;
}) {
const sp = headers().get('searchParams') || '';
return (
<div>
<Comp1 searchParams={decodeState(sp, stateShape)} />
{children}
</div>
);
}
```
</details>
##### 使用任意状态形状(不推荐)
<details>
<Summary>示例</Summary>
```typescript
'use client'
import { useUrlState } from 'state-in-url/next';
const someObj = {};
function SettingsComponent() {
const { urlState, setUrl, setState } = useUrlState<object>(someObj);
}
```
</details>
### 用于 Remix.js 的 useUrlState hook
API 与 Next.js 版本相同,但可以传递 [NavigateOptions](https://github.com/remix-run/react-router/blob/bc693ed9f39170bda13b9e1b282fb8e9d5925f66/packages/react-router/lib/context.ts#L99) 类型的选项。
[API 文档](packages/urlstate/remix/useUrlState)
#### 示例
```typescript
export const form: Form = {
name: '',
age: undefined,
agree_to_terms: false,
tags: [],
};
type Form = {
name: string;
age?: number;
agree_to_terms: boolean;
tags: { id: string; value: { text: string; time: Date } }[];
};
```
```typescript
import { useUrlState } from 'state-in-url/remix';
import { form } from './form';
function TagsComponent() {
const { urlState, setUrl, setState } = useUrlState(form);
const onChangeTags = React.useCallback(
(tag: (typeof tags)[number]) => {
setUrl((curr) => ({
...curr,
tags: curr.tags.find((t) => t.id === tag.id)
? curr.tags.filter((t) => t.id !== tag.id)
: curr.tags.concat(tag),
}));
},
[setUrl],
);
return (
<div>
<Field text="标签">
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<Tag
active={!!urlState.tags.find((t) => t.id === tag.id)}
text={tag.value.text}
onClick={() => onChangeTags(tag)}
key={tag.id}
/>
))}
</div>
</Field>
<input value={urlState.name}
onChange={(ev) => { setState(curr => ({ ...curr, name: ev.target.value })) }}
// 可以立即更新状态,但根据需要将更改同步到 url
onBlur={() => setUrl()}
/>
</div>
);
}
const tags = [
{
id: '1',
value: { text: 'React.js', time: new Date('2024-07-17T04:53:17.000Z') },
},
{
id: '2',
value: { text: 'Next.js', time: new Date('2024-07-18T04:53:17.000Z') },
},
{
id: '3',
value: { text: 'TailwindCSS', time: new Date('2024-07-19T04:53:17.000Z') },
},
];
```
[示例代码](packages/example-remix2/app/routes/Form-for-test.tsx)
### 用于 React-Router 的 useUrlState hook
API 与 Next.js 版本相同,但可以传递 [NavigateOptions](https://github.com/remix-run/react-router/blob/bc693ed9f39170bda13b9e1b282fb8e9d5925f66/packages/react-router/lib/context.ts#L99) 类型的选项。
[API 文档](packages/urlstate/react-router/useUrlState)
#### 示例
```typescript
export const form: Form = {
name: '',
age: undefined,
agree_to_terms: false,
tags: [],
};
type Form = {
name: string;
age?: number;
agree_to_terms: boolean;
tags: { id: string; value: { text: string; time: Date } }[];
};
```
```typescript
import { useUrlState } from 'state-in-url/react-router';
// 对于 react-router v6
// import { useUrlState } from 'state-in-url/react-router6';
import { form } from './form';
function TagsComponent() {
const { urlState, setUrl, setState } = useUrlState(form);
const onChangeTags = React.useCallback(
(tag: (typeof tags)[number]) => {
setUrl((curr) => ({
...curr,
tags: curr.tags.find((t) => t.id === tag.id)
? curr.tags.filter((t) => t.id !== tag.id)
: curr.tags.concat(tag),
}));
},
[setUrl],
);
return (
<div>
<Field text="标签">
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<Tag
active={!!urlState.tags.find((t) => t.id === tag.id)}
text={tag.value.text}
onClick={() => onChangeTags(tag)}
key={tag.id}
/>
))}
</div>
</Field>
<input value={urlState.name}
onChange={(ev) => { setState(curr => ({ ...curr, name: ev.target.value })) }}
// 可以立即更新状态,但根据需要将更改同步到 url
onBlur={() => setUrl()}
/>
</div>
);
}
const tags = [
{
id: '1',
value: { text: 'React.js', time: new Date('2024-07-17T04:53:17.000Z') },
},
{
id: '2',
value: { text: 'Next.js', time: new Date('2024-07-18T04:53:17.000Z') },
},
{
id: '3',
value: { text: 'TailwindCSS', time: new Date('2024-07-19T04:53:17.000Z') },
},
];
```
[示例代码](packages/example-react-router6/src/Form-for-test.tsx)
## 实用技巧
##### 自定义 hook 以便捷地处理状态切片
<details>
<Summary>示例</Summary>
```typescript
'use client';
import React from 'react';
import { useUrlState } from 'state-in-url/next';
const form: Form = {
name: '',
age: undefined,
agree_to_terms: false,
tags: [],
};
type Form = {
name: string;
age?: number;
agree_to_terms: boolean;
tags: {id: string; value: {text: string; time: Date } }[];
};
export const useFormState = ({ searchParams }: { searchParams?: object }) => {
const { urlState, setUrl: setUrlBase, reset } = useUrlState(form, {
searchParams,
});
// 第一次导航将推送新的历史记录条目
// 所有后续操作将仅替换该条目
// 这样历史记录中只有 2 个条目 - ['/url', '/url?key=param']
const replace = React.useRef(false);
const setUrl = React.useCallback((
state: Parameters<typeof setUrlBase>[0],
opts?: Parameters<typeof setUrlBase>[1]
) => {
setUrlBase(state, { replace: replace.current, ...opts });
replace.current = true;
}, [setUrlBase]);
return { urlState, setUrl, resetUrl: reset };
};
```
</details>
<hr />
##### 使用复杂状态形状
<details>
<Summary>示例</Summary>
```typescript
export const form: Form = {
name: '',
age: undefined,
agree_to_terms: false,
tags: [],
};
type Form = {
name: string;
age?: number;
agree_to_terms: boolean;
tags: { id: string; value: { text: string; time: Date } }[];
};
```
```typescript
'use client'
import { useUrlState } from 'state-in-url/next';
import { form } from './form';
function TagsComponent() {
// `urlState` 将从 Form 类型推断!
const { urlState, setUrl } = useUrlState(form);
const onChangeTags = React.useCallback(
(tag: (typeof tags)[number]) => {
setUrl((curr) => ({
...curr,
tags: curr.tags.find((t) => t.id === tag.id)
? curr.tags.filter((t) => t.id !== tag.id)
: curr.tags.concat(tag),
}));
},
[setUrl],
);
return (
<div>
<Field text="标签">
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<Tag
active={!!urlState.tags.find((t) => t.id === tag.id)}
text={tag.value.text}
onClick={() => onChangeTags(tag)}
key={tag.id}
/>
))}
</div>
</Field>
</div>
);
}
const tags = [
{
id: '1',
value: { text: 'React.js', time: new Date('2024-07-17T04:53:17.000Z') },
},
{
id: '2',
value: { text: 'Next.js', time: new Date('2024-07-18T04:53:17.000Z') },
},
{
id: '3',
value: { text: 'TailwindCSS', time: new Date('2024-07-19T04:53:17.000Z') },
},
];
```
[演示页面示例代码](https://github.com/asmyshlyaev177/state-in-url/blob/master/packages/example-nextjs15/src/app/Form.tsx)
</details>
##### 仅更新状态并手动同步到 URL
<details>
<Summary>示例</Summary>
```typescript
const timer = React.useRef(0 as unknown as NodeJS.Timeout);
React.useEffect(() => {
clearTimeout(timer.current);
timer.current = setTimeout(() => {
// 将按内容而不是引用比较状态,仅对新值触发更新
setUrl(urlState);
}, 500);
return () => {
clearTimeout(timer.current);
};
}, [urlState, setUrl]);
```
在 `onBlur` 时同步状态将更符合实际使用情况。
```typescript
<input onBlur={() => updateUrl()} .../>
```
</details>
## 其他 hooks 和辅助函数
### `useUrlStateBase` hook 用于其他路由器
用于使用其他路由器(例如 react-router 或 tanstack router)创建自己的 `useUrlState` hooks 的 Hooks。
[API 文档](packages/urlstate/useUrlStateBase)
### `useSharedState` hook 用于 React.js
在任何 React 组件之间共享状态的 Hook,已在 Next.js 和 Vite 中测试。
```typescript
'use client'
import { useSharedState } from 'state-in-url';
export const someState = { name: '' };
function SettingsComponent() {
const { state, setState } = useSharedState(someState);
}
```
[API 文档](packages/urlstate/useSharedState/README.md)
### `useUrlEncode` hook 用于 React.js
[API 文档](packages/urlstate/useUrlEncode/README.md)
### `encodeState` 和 `decodeState` 辅助函数
[API 文档](packages/urlstate/encodeState/README.md)
### `encode` 和 `decode` 辅助函数
[API 文档](packages/urlstate/encoder/README.md)
## 最佳实践
- 将状态形状定义为常量
- 使用 TypeScript 以增强类型安全和自动完成
- 避免在 URL 参数中存储敏感信息(SSN、API 密钥等)
- 使用此[扩展](https://marketplace.visualstudio.com/items?itemName=yoavbls.pretty-ts-errors)以获得可读的 TS 错误
可以为状态切片创建状态 hooks,并在整个应用程序中重用它们。例如:
```Typescript
type UserState = {
name: string;
age: number;
other: { id: string, value: number }[]
};
const userState = {
name: '',
age: 0,
other: [],
};
export const useUserState = () => {
const { urlState, setUrl, reset } = useUrlState(userState);
// 其他逻辑
// 导航到其他页面时重置查询参数
React.useEffect(() => {
return reset
}, [])
return { userState: urlState, setUserState: setUrl };;
}
```
## 注意事项
1. 只能传递可序列化的值,`Function`、`BigInt` 或 `Symbol` 不起作用,可能像 `ArrayBuffer` 这样的东西也不行。所有可以序列化为 JSON 的内容都可以工作。
2. Vercel 服务器将标头大小(查询字符串和其他内容)限制为 **14KB**,因此请将 URL 状态保持在约 5000 个字以下。<https://vercel.com/docs/errors/URL_TOO_LONG>
3. 已在带有 app router 的 `next.js` 14/15 中测试,没有计划支持 pages。
## 其他
### 贡献和/或本地运行
参见[贡献文档](CONTRIBUTING.md)
## 路线图
- [x] 用于 `Next.js` 的 hook
- [x] 用于 `react-router` 的 hook
- [x] 用于 `remix` 的 hook
- [ ] 用于 `svelte` 的 hook
- [ ] 用于 `astro` 的 hook
- [ ] 用于在 hash 中存储状态的 hook ?
## 联系和支持
- 创建 [GitHub issue](https://github.com/asmyshlyaev177/state-in-url/issues) 用于错误报告、功能请求或问题咨询
## [更新日志](CHANGELOG.md)
## 提及
- [This Week in React 209](https://thisweekinreact.com/newsletter/209)
- [JavaScript Weekly](https://javascriptweekly.com/issues/741)
- [This Week in React 240](https://thisweekinreact.com/newsletter/240)
## 许可证
该项目根据 [MIT 许可证](LICENSE)授权。
## 个人网站
[作品集网站](https://asmyshlyaev177.dev)
## 灵感来源
[NUQS](https://github.com/47ng/nuqs)
[在 Vue 中使用 URL 存储状态](https://dev.to/jacobandrewsky/using-url-to-store-state-in-vue-275c)
[在 URL 中存储状态](https://antonz.org/storing-state/)
[NextJS useSearchParams](https://nextjs.org/docs/app/api-reference/functions/use-search-params)