state-in-url 6.3.0 → 6.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.CN.md +4 -776
- package/README.es.md +836 -0
- package/README.fr.md +836 -0
- package/README.ja.md +836 -0
- package/{README.KO.md → README.ko.md} +69 -8
- package/README.md +34 -3
- package/README.pt-BR.md +836 -0
- package/README.ru.md +836 -0
- package/README.vi.md +836 -0
- package/README.zh-CN.md +836 -0
- package/dist/index.d.ts +9 -8
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/package.json +28 -11
- package/dist/useLinkProps/hrefWithState.d.ts +3 -0
- package/dist/useLinkProps/hrefWithState.js +1 -0
- package/dist/useLinkProps/hrefWithState.mjs +1 -0
- package/dist/useLinkProps/index.d.ts +1 -0
- package/dist/useLinkProps/index.js +1 -0
- package/dist/useLinkProps/index.mjs +1 -0
- package/dist/useLinkProps/useLinkProps.d.ts +39 -0
- package/dist/useLinkProps/useLinkProps.js +1 -0
- package/dist/useLinkProps/useLinkProps.mjs +1 -0
- package/dist/useUrlStateBase/useUrlStateBase.d.ts +3 -3
- package/dist/useUrlStateBase/useUrlStateBase.js +1 -1
- package/dist/useUrlStateBase/useUrlStateBase.mjs +1 -1
- package/dist/utils.d.ts +2 -0
- package/dist/utils.js +1 -1
- package/dist/utils.mjs +1 -1
- package/package.json +28 -11
- package/skills/feature-state-hook/SKILL.md +15 -2
- package/skills/form-library-integration/SKILL.md +1 -1
- package/skills/input-handling/SKILL.md +1 -1
- package/skills/nextjs-ssr/SKILL.md +1 -1
- package/skills/react-router-remix-setup/SKILL.md +1 -1
- package/skills/shared-state-no-url/SKILL.md +1 -1
package/README.ru.md
ADDED
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
<!-- i18n:start -->
|
|
2
|
+
[English](./README.md) · [简体中文](./README.zh-CN.md) · [日本語](./README.ja.md) · [한국어](./README.ko.md) · Русский · [Español](./README.es.md) · [Português (BR)](./README.pt-BR.md) · [Français](./README.fr.md) · [Tiếng Việt](./README.vi.md)
|
|
3
|
+
<!-- i18n:meta locale=ru source=README.md source-blob=bbf45a10aae9e88f68b27d84f51ee3e7e8a1e436 status=translated -->
|
|
4
|
+
<!-- i18n:end -->
|
|
5
|
+
|
|
6
|
+
<div align="center">
|
|
7
|
+
<img src="/packages/example-nextjs14/public/Logo_symbol.png" alt="логотип state-in-url" width="120px" />
|
|
8
|
+
|
|
9
|
+
# State in url
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<div align="center">
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
<div align="center">
|
|
16
|
+
|
|
17
|
+
[](https://asmyshlyaev177.dev)
|
|
18
|
+
|
|
19
|
+
[](https://www.npmjs.com/package/state-in-url)
|
|
20
|
+

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

|
|
24
|
+
[](https://app.codacy.com/gh/asmyshlyaev177/state-in-url/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_coverage)
|
|
25
|
+
[](https://github.com/asmyshlyaev177/state-in-url/)
|
|
26
|
+
[]([https://github.com/semantic-release/semantic-release](https://github.com/asmyshlyaev177/state-in-url))
|
|
27
|
+
|
|
28
|
+
[](https://scorecard.dev/viewer/?uri=github.com/asmyshlyaev177/state-in-url)
|
|
29
|
+
[](https://www.bestpractices.dev/projects/9679)
|
|
30
|
+
[](https://github.com/asmyshlyaev177/state-in-url/blob/master/LICENSE)
|
|
31
|
+
<!-- or by embedding this in your HTML:
|
|
32
|
+
<a href="https://www.bestpractices.dev/projects/9679"><img src="https://www.bestpractices.dev/projects/9679/badge"></a> -->
|
|
33
|
+
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<div align="center">
|
|
37
|
+
|
|
38
|
+
<h4 align="center">Не стесняйтесь открывать issue, если нашли баг или хотите запросить новую функцию</h4>
|
|
39
|
+
|
|
40
|
+

|
|
41
|
+
|
|
42
|
+
# Демо
|
|
43
|
+
|
|
44
|
+
<a href="https://state-in-url.dev" target="_blank">Демо</a> |
|
|
45
|
+
<a href="https://state-in-url.netlify.app/" target="_blank">Зеркало</a>
|
|
46
|
+
|
|
47
|
+
<a href="https://github.com/asmyshlyaev177/state-in-url/blob/master/Limits.md" target="_blank">Ограничение размера URI — безопасно <b>до 12KB</b></a>
|
|
48
|
+
|
|
49
|
+
<hr />
|
|
50
|
+
|
|
51
|
+
Добавьте <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>, чтобы поддержать проект!
|
|
52
|
+
|
|
53
|
+
Буду признателен за ваши отзывы и мнения в [обсуждениях](https://github.com/asmyshlyaev177/state-in-url/discussions/1)
|
|
54
|
+
|
|
55
|
+
Поделитесь, если это было вам полезно.
|
|
56
|
+
[X.com](https://twitter.com/intent/tweet?&url=https://github.com/asmyshlyaev177/state-in-url)
|
|
57
|
+
[LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https://github.com/asmyshlyaev177/state-in-url)
|
|
58
|
+
[FB](https://www.facebook.com/sharer.php?u=https://github.com/asmyshlyaev177/state-in-url)
|
|
59
|
+
[VK](http://vk.com/share.php?url=https://github.com/asmyshlyaev177/state-in-url)
|
|
60
|
+
|
|
61
|
+
<hr />
|
|
62
|
+
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
[Сразу покажите код!](#useurlstate)
|
|
66
|
+
|
|
67
|
+
## Зачем использовать `state-in-url`?
|
|
68
|
+
|
|
69
|
+
Храните любое пользовательское состояние в параметрах запроса — представьте JSON в URL браузера. При этом сохраняются типы и структура данных: например, числа декодируются как числа, а не строки, даты — как даты и т. д., поддерживаются объекты и массивы.
|
|
70
|
+
До безобразия просто, быстро и со статической проверкой TypeScript. Глубокие ссылки, то есть синхронизация URL, становятся лёгкими.
|
|
71
|
+
|
|
72
|
+
Содержит hook `useUrlState` для Next.js и react-router, а также вспомогательные функции для всего остального на JS.
|
|
73
|
+
Современные браузеры поддерживают огромные URL, а пользователям всё равно на строку запроса (это просто «выделить всё и скопировать/вставить»).
|
|
74
|
+
|
|
75
|
+
Пора использовать строку запроса для управления состоянием, как это и было задумано изначально.
|
|
76
|
+
Библиотека делает всю рутинную работу за вас.
|
|
77
|
+
|
|
78
|
+
Библиотека — хорошая альтернатива NUQS.
|
|
79
|
+
|
|
80
|
+
### Сценарии использования
|
|
81
|
+
|
|
82
|
+
- Хранение несохранённых пользовательских форм или фильтров страниц в URL
|
|
83
|
+
- Синхронизация URL с состоянием React
|
|
84
|
+
- Просто синхронизация данных между несвязанными клиентскими компонентами, не трогая URI
|
|
85
|
+
- URL, которыми можно делиться, вместе с состоянием приложения (глубокие ссылки, синхронизация состояния в URL)
|
|
86
|
+
- Простое сохранение состояния между перезагрузками страницы
|
|
87
|
+
|
|
88
|
+
### Возможности
|
|
89
|
+
|
|
90
|
+
- 🧩 **Просто**: Без провайдеров, редьюсеров, бойлерплейта и новых концепций, API похож на `React.useState`
|
|
91
|
+
- 📘 **Проверка/автодополнение TypeScript**: Состояние — просто объект, автоматическая статическая проверка в IDE/тестах по описанию TypeScript
|
|
92
|
+
- ✨ **Сложные данные**: Вложенные объекты, даты и массивы, работает как JSON, но в URL
|
|
93
|
+
- ☂ **Значения по умолчанию**: Возвращает значения по умолчанию, если параметра нет в URL
|
|
94
|
+
- ⌨ **Упорядоченно**: Все возможные значения заданы сразу — защищает от обращения к несуществующему ключу
|
|
95
|
+
- **совместимо**: Сохраняет сторонние параметры запроса как есть
|
|
96
|
+
- **гибко**: Можно использовать несколько объектов состояния на одной странице — просто с разными ключами
|
|
97
|
+
- **Быстро**: Минимум ре-рендеров, около [1ms](https://github.com/asmyshlyaev177/state-in-url/blob/87c8c7c995c5cd7d9e7aa039c30bfe64b24abe4b/packages/urlstate/encoder/encoder.test.ts#L185) на кодирование и декодирование большого объекта
|
|
98
|
+
- **Серверный рендеринг**: Можно использовать в серверных компонентах, поддерживаются Next.js 14, 15 и 16
|
|
99
|
+
- **Лёгкость**: Ноль зависимостей, библиотека меньше 2KB
|
|
100
|
+
- **DX**: Хороший developer experience, документация, JSDoc-комментарии и примеры
|
|
101
|
+
- **Гибкость по фреймворкам**: Hook'и для `Next.js` и `react-router`, вспомогательные функции для других фреймворков или чистого JS
|
|
102
|
+
- **Хорошо протестирована**: [Юнит-тесты и тесты Playwright для Chrome/Firefox/Safari](https://github.com/asmyshlyaev177/state-in-url/actions/workflows/tests.yml)
|
|
103
|
+
- **Разрешительная лицензия**: MIT
|
|
104
|
+
|
|
105
|
+
## Содержание
|
|
106
|
+
|
|
107
|
+
<!-- toc:start -->
|
|
108
|
+
|
|
109
|
+
- [State in url](#state-in-url)
|
|
110
|
+
- [Демо](#демо)
|
|
111
|
+
- [Зачем использовать `state-in-url`?](#зачем-использовать-state-in-url)
|
|
112
|
+
- [Сценарии использования](#сценарии-использования)
|
|
113
|
+
- [Возможности](#возможности)
|
|
114
|
+
- [Содержание](#содержание)
|
|
115
|
+
- [Установка](#установка)
|
|
116
|
+
- [1. Установка пакета](#1-установка-пакета)
|
|
117
|
+
- [2. Правка tsconfig.json](#2-правка-tsconfigjson)
|
|
118
|
+
- [Использование с AI-агентами для кодинга](#использование-с-ai-агентами-для-кодинга)
|
|
119
|
+
- [useUrlState](#useurlstate)
|
|
120
|
+
- [Hook useUrlState для Next.js](#hook-useurlstate-для-nextjs)
|
|
121
|
+
- [Примеры использования](#примеры-использования)
|
|
122
|
+
- [Базовый](#базовый)
|
|
123
|
+
- [С серверным рендерингом](#с-серверным-рендерингом)
|
|
124
|
+
- [Использование hook в компоненте `layout`](#использование-hook-в-компоненте-layout)
|
|
125
|
+
- [С произвольной формой состояния (не рекомендуется)](#с-произвольной-формой-состояния-не-рекомендуется)
|
|
126
|
+
- [Hook useUrlState для Remix.js](#hook-useurlstate-для-remixjs)
|
|
127
|
+
- [Пример](#пример)
|
|
128
|
+
- [Hook useUrlState для React-Router](#hook-useurlstate-для-react-router)
|
|
129
|
+
- [Пример](#пример-1)
|
|
130
|
+
- [Рецепты](#рецепты)
|
|
131
|
+
- [Пользовательский hook для удобной работы со срезом состояния](#пользовательский-hook-для-удобной-работы-со-срезом-состояния)
|
|
132
|
+
- [Со сложной формой состояния](#со-сложной-формой-состояния)
|
|
133
|
+
- [Обновлять только состояние и синхронизировать с URL вручную](#обновлять-только-состояние-и-синхронизировать-с-url-вручную)
|
|
134
|
+
- [Другие hook'и и вспомогательные функции](#другие-hookи-и-вспомогательные-функции)
|
|
135
|
+
- [Hook `useUrlStateBase` для других роутеров](#hook-useurlstatebase-для-других-роутеров)
|
|
136
|
+
- [Hook `useSharedState` для React.js](#hook-usesharedstate-для-reactjs)
|
|
137
|
+
- [Hook `useLinkProps` для React.js](#hook-uselinkprops-для-reactjs)
|
|
138
|
+
- [Hook `useUrlEncode` для React.js](#hook-useurlencode-для-reactjs)
|
|
139
|
+
- [Вспомогательные функции `encodeState` и `decodeState`](#вспомогательные-функции-encodestate-и-decodestate)
|
|
140
|
+
- [Вспомогательные функции `encode` и `decode`](#вспомогательные-функции-encode-и-decode)
|
|
141
|
+
- [Рекомендации](#рекомендации)
|
|
142
|
+
- [Подводные камни](#подводные-камни)
|
|
143
|
+
- [Прочее](#прочее)
|
|
144
|
+
- [Участие и/или локальный запуск](#участие-иили-локальный-запуск)
|
|
145
|
+
- [Дорожная карта](#дорожная-карта)
|
|
146
|
+
- [Контакты и поддержка](#контакты-и-поддержка)
|
|
147
|
+
- [История изменений](#история-изменений)
|
|
148
|
+
- [Упоминания](#упоминания)
|
|
149
|
+
- [Лицензия](#лицензия)
|
|
150
|
+
- [Наймите меня](#наймите-меня)
|
|
151
|
+
- [Вдохновение](#вдохновение)
|
|
152
|
+
|
|
153
|
+
<!-- toc:end -->
|
|
154
|
+
|
|
155
|
+
## Установка
|
|
156
|
+
|
|
157
|
+
### 1. Установка пакета
|
|
158
|
+
|
|
159
|
+
```sh
|
|
160
|
+
# npm
|
|
161
|
+
npm install --save state-in-url
|
|
162
|
+
# yarn
|
|
163
|
+
yarn add state-in-url
|
|
164
|
+
# pnpm
|
|
165
|
+
pnpm add state-in-url
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### 2. Правка tsconfig.json
|
|
169
|
+
|
|
170
|
+
В `tsconfig.json` в `compilerOptions` задайте `"moduleResolution": "Bundler"`, либо `"moduleResolution": "Node16"`, либо `"moduleResolution": "NodeNext"`.
|
|
171
|
+
Возможно, понадобится задать `"module": "ES2022"` или `"module": "ESNext"`
|
|
172
|
+
|
|
173
|
+
## Использование с AI-агентами для кодинга
|
|
174
|
+
|
|
175
|
+
`state-in-url` поставляется с файлами навыков (skills) для [@tanstack/intent](https://tanstack.com/intent/latest/docs/overview), поэтому AI-агенты (Claude Code, Cursor, Copilot, Codex и т. д.) загружают правильные паттерны и избегают типичных ошибок при работе с библиотекой. После установки `state-in-url` выполните один раз в своём проекте:
|
|
176
|
+
|
|
177
|
+
```sh
|
|
178
|
+
npx @tanstack/intent@latest install
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Это настроит установленного агента так, чтобы он находил навыки в `node_modules/state-in-url/skills/`. Список доступных навыков — `npx @tanstack/intent@latest list`.
|
|
182
|
+
|
|
183
|
+
## useUrlState
|
|
184
|
+
|
|
185
|
+
Основной hook, который принимает начальное состояние как параметр и возвращает объект состояния, колбэк для обновления URL и колбэк для обновления только состояния.
|
|
186
|
+
Все компоненты, использующие один и тот же объект `state`, синхронизируются автоматически.
|
|
187
|
+
|
|
188
|
+
### Hook useUrlState для Next.js
|
|
189
|
+
|
|
190
|
+
[Полная документация по API](packages/urlstate/next/useUrlState)
|
|
191
|
+
|
|
192
|
+
[Пример React-Router](#hook-useurlstate-для-react-router)
|
|
193
|
+
|
|
194
|
+
#### Примеры использования
|
|
195
|
+
|
|
196
|
+
##### Базовый
|
|
197
|
+
|
|
198
|
+
1. Определите форму состояния со значениями по умолчанию
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
// userState.ts
|
|
202
|
+
// В URL попадут только параметры, чьё значение отличается от значения по умолчанию.
|
|
203
|
+
export const userState: UserState = { name: '', age: 0 }
|
|
204
|
+
|
|
205
|
+
// Используйте `Type`, а не `Interface`!
|
|
206
|
+
type UserState = { name: string, age: number }
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
2. Импортируйте и используйте
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
'use client'
|
|
213
|
+
import { useUrlState } from 'state-in-url/next';
|
|
214
|
+
|
|
215
|
+
import { userState } from './userState';
|
|
216
|
+
|
|
217
|
+
function MyComponent() {
|
|
218
|
+
// Можно передать аргумент `replace` — он управляет тем, использует ли `setUrl` `router.push` или `router.replace`, по умолчанию replace=true
|
|
219
|
+
// Можно передать `searchParams` из серверных компонентов; передайте `useHistory: false`, если нужно что-то получить в серверном компоненте
|
|
220
|
+
const { urlState, setUrl, setState } = useUrlState(userState);
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<div>
|
|
224
|
+
// urlState.name вернёт значение по умолчанию из `userState`, если URL пуст
|
|
225
|
+
<input value={urlState.name}
|
|
226
|
+
// Тот же API, что и у React.useState, например setUrl(currVal => currVal + 1)
|
|
227
|
+
onChange={(ev) => setUrl({ name: ev.target.value }) }
|
|
228
|
+
/>
|
|
229
|
+
<input value={urlState.age}
|
|
230
|
+
onChange={(ev) => setUrl({ age: +ev.target.value }) }
|
|
231
|
+
/>
|
|
232
|
+
|
|
233
|
+
<input value={urlState.name}
|
|
234
|
+
onChange={(ev) => { setState(curr => ({ ...curr, name: ev.target.value })) }}
|
|
235
|
+
// Можно обновить состояние сразу, но синхронизировать изменения с URL по мере необходимости
|
|
236
|
+
onBlur={() => setUrl()}
|
|
237
|
+
/>
|
|
238
|
+
|
|
239
|
+
<button onClick={() => setUrl((_, initial) => initial)}>
|
|
240
|
+
Reset
|
|
241
|
+
</button>
|
|
242
|
+
|
|
243
|
+
</div>
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
##### С серверным рендерингом
|
|
249
|
+
|
|
250
|
+
<details>
|
|
251
|
+
<Summary>Пример</Summary>
|
|
252
|
+
|
|
253
|
+
```typescript
|
|
254
|
+
export default async function Home({ searchParams }: { searchParams: object }) {
|
|
255
|
+
return (
|
|
256
|
+
<Form searchParams={searchParams} />
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Form.tsx
|
|
261
|
+
'use client'
|
|
262
|
+
import React from 'react';
|
|
263
|
+
import { useUrlState } from 'state-in-url/next';
|
|
264
|
+
import { form } from './form';
|
|
265
|
+
|
|
266
|
+
const Form = ({ searchParams }: { searchParams: object }) => {
|
|
267
|
+
const { urlState, setState, setUrl } = useUrlState(form, { searchParams });
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
</details>
|
|
272
|
+
|
|
273
|
+
##### Использование hook в компоненте `layout`
|
|
274
|
+
|
|
275
|
+
<details>
|
|
276
|
+
<Summary>Пример</Summary>
|
|
277
|
+
Это непростой момент: nextjs с app router не позволяет получить доступ к searchParams со стороны сервера. Есть обходной путь через middleware, но он некрасивый и может перестать работать после обновления nextjs.
|
|
278
|
+
|
|
279
|
+
```typescript
|
|
280
|
+
// добавьте в подходящий `layout.tsx`
|
|
281
|
+
export const runtime = 'edge';
|
|
282
|
+
|
|
283
|
+
// middleware.ts
|
|
284
|
+
import type { NextRequest } from 'next/server';
|
|
285
|
+
import { NextResponse } from 'next/server';
|
|
286
|
+
|
|
287
|
+
export function middleware(request: NextRequest) {
|
|
288
|
+
const url = request.url?.includes('_next') ? null : request.url;
|
|
289
|
+
const sp = url?.split?.('?')?.[1] || '';
|
|
290
|
+
|
|
291
|
+
const response = NextResponse.next();
|
|
292
|
+
|
|
293
|
+
if (url !== null) {
|
|
294
|
+
response.headers.set('searchParams', sp);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return response;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Целевой компонент layout
|
|
301
|
+
import { headers } from 'next/headers';
|
|
302
|
+
import { decodeState } from 'state-in-url/encodeState';
|
|
303
|
+
|
|
304
|
+
export default async function Layout({
|
|
305
|
+
children,
|
|
306
|
+
}: {
|
|
307
|
+
children: React.ReactNode;
|
|
308
|
+
}) {
|
|
309
|
+
const sp = headers().get('searchParams') || '';
|
|
310
|
+
|
|
311
|
+
return (
|
|
312
|
+
<div>
|
|
313
|
+
<Comp1 searchParams={decodeState(sp, stateShape)} />
|
|
314
|
+
{children}
|
|
315
|
+
</div>
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
</details>
|
|
323
|
+
|
|
324
|
+
##### С произвольной формой состояния (не рекомендуется)
|
|
325
|
+
|
|
326
|
+
<details>
|
|
327
|
+
<Summary>Пример</Summary>
|
|
328
|
+
|
|
329
|
+
```typescript
|
|
330
|
+
'use client'
|
|
331
|
+
import { useUrlState } from 'state-in-url/next';
|
|
332
|
+
|
|
333
|
+
const someObj = {};
|
|
334
|
+
|
|
335
|
+
function SettingsComponent() {
|
|
336
|
+
const { urlState, setUrl, setState } = useUrlState<object>(someObj);
|
|
337
|
+
}
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
</details>
|
|
341
|
+
|
|
342
|
+
### Hook useUrlState для Remix.js
|
|
343
|
+
|
|
344
|
+
API такой же, как у версии для Next.js, за исключением того, что можно передать опции из типа [NavigateOptions](https://github.com/remix-run/react-router/blob/bc693ed9f39170bda13b9e1b282fb8e9d5925f66/packages/react-router/lib/context.ts#L99).
|
|
345
|
+
|
|
346
|
+
[Документация по API](packages/urlstate/remix/useUrlState)
|
|
347
|
+
|
|
348
|
+
#### Пример
|
|
349
|
+
|
|
350
|
+
```typescript
|
|
351
|
+
export const form: Form = {
|
|
352
|
+
name: '',
|
|
353
|
+
age: undefined,
|
|
354
|
+
agree_to_terms: false,
|
|
355
|
+
tags: [],
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
type Form = {
|
|
359
|
+
name: string;
|
|
360
|
+
age?: number;
|
|
361
|
+
agree_to_terms: boolean;
|
|
362
|
+
tags: { id: string; value: { text: string; time: Date } }[];
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
```typescript
|
|
368
|
+
import { useUrlState } from 'state-in-url/remix';
|
|
369
|
+
|
|
370
|
+
import { form } from './form';
|
|
371
|
+
|
|
372
|
+
function TagsComponent() {
|
|
373
|
+
const { urlState, setUrl, setState } = useUrlState(form);
|
|
374
|
+
|
|
375
|
+
const onChangeTags = React.useCallback(
|
|
376
|
+
(tag: (typeof tags)[number]) => {
|
|
377
|
+
setUrl((curr) => ({
|
|
378
|
+
...curr,
|
|
379
|
+
tags: curr.tags.find((t) => t.id === tag.id)
|
|
380
|
+
? curr.tags.filter((t) => t.id !== tag.id)
|
|
381
|
+
: curr.tags.concat(tag),
|
|
382
|
+
}));
|
|
383
|
+
},
|
|
384
|
+
[setUrl],
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
return (
|
|
388
|
+
<div>
|
|
389
|
+
<Field text="Tags">
|
|
390
|
+
<div className="flex flex-wrap gap-2">
|
|
391
|
+
{tags.map((tag) => (
|
|
392
|
+
<Tag
|
|
393
|
+
active={!!urlState.tags.find((t) => t.id === tag.id)}
|
|
394
|
+
text={tag.value.text}
|
|
395
|
+
onClick={() => onChangeTags(tag)}
|
|
396
|
+
key={tag.id}
|
|
397
|
+
/>
|
|
398
|
+
))}
|
|
399
|
+
</div>
|
|
400
|
+
</Field>
|
|
401
|
+
|
|
402
|
+
<input value={urlState.name}
|
|
403
|
+
onChange={(ev) => { setState(curr => ({ ...curr, name: ev.target.value })) }}
|
|
404
|
+
// Можно обновить состояние сразу, но синхронизировать изменения с URL по мере необходимости
|
|
405
|
+
onBlur={() => setUrl()}
|
|
406
|
+
/>
|
|
407
|
+
</div>
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const tags = [
|
|
412
|
+
{
|
|
413
|
+
id: '1',
|
|
414
|
+
value: { text: 'React.js', time: new Date('2024-07-17T04:53:17.000Z') },
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
id: '2',
|
|
418
|
+
value: { text: 'Next.js', time: new Date('2024-07-18T04:53:17.000Z') },
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
id: '3',
|
|
422
|
+
value: { text: 'TailwindCSS', time: new Date('2024-07-19T04:53:17.000Z') },
|
|
423
|
+
},
|
|
424
|
+
];
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
[Пример кода](packages/example-remix2/app/routes/Form-for-test.tsx)
|
|
428
|
+
|
|
429
|
+
### Hook useUrlState для React-Router
|
|
430
|
+
|
|
431
|
+
API такой же, как у версии для Next.js, за исключением того, что можно передать опции из типа [NavigateOptions](https://github.com/remix-run/react-router/blob/bc693ed9f39170bda13b9e1b282fb8e9d5925f66/packages/react-router/lib/context.ts#L99).
|
|
432
|
+
|
|
433
|
+
[Документация по API](packages/urlstate/react-router/useUrlState)
|
|
434
|
+
|
|
435
|
+
#### Пример
|
|
436
|
+
|
|
437
|
+
```typescript
|
|
438
|
+
export const form: Form = {
|
|
439
|
+
name: '',
|
|
440
|
+
age: undefined,
|
|
441
|
+
agree_to_terms: false,
|
|
442
|
+
tags: [],
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
type Form = {
|
|
446
|
+
name: string;
|
|
447
|
+
age?: number;
|
|
448
|
+
agree_to_terms: boolean;
|
|
449
|
+
tags: { id: string; value: { text: string; time: Date } }[];
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
```typescript
|
|
455
|
+
import { useUrlState } from 'state-in-url/react-router';
|
|
456
|
+
// для react-router v6
|
|
457
|
+
// import { useUrlState } from 'state-in-url/react-router6';
|
|
458
|
+
|
|
459
|
+
import { form } from './form';
|
|
460
|
+
|
|
461
|
+
function TagsComponent() {
|
|
462
|
+
const { urlState, setUrl, setState } = useUrlState(form);
|
|
463
|
+
|
|
464
|
+
const onChangeTags = React.useCallback(
|
|
465
|
+
(tag: (typeof tags)[number]) => {
|
|
466
|
+
setUrl((curr) => ({
|
|
467
|
+
...curr,
|
|
468
|
+
tags: curr.tags.find((t) => t.id === tag.id)
|
|
469
|
+
? curr.tags.filter((t) => t.id !== tag.id)
|
|
470
|
+
: curr.tags.concat(tag),
|
|
471
|
+
}));
|
|
472
|
+
},
|
|
473
|
+
[setUrl],
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
return (
|
|
477
|
+
<div>
|
|
478
|
+
<Field text="Tags">
|
|
479
|
+
<div className="flex flex-wrap gap-2">
|
|
480
|
+
{tags.map((tag) => (
|
|
481
|
+
<Tag
|
|
482
|
+
active={!!urlState.tags.find((t) => t.id === tag.id)}
|
|
483
|
+
text={tag.value.text}
|
|
484
|
+
onClick={() => onChangeTags(tag)}
|
|
485
|
+
key={tag.id}
|
|
486
|
+
/>
|
|
487
|
+
))}
|
|
488
|
+
</div>
|
|
489
|
+
</Field>
|
|
490
|
+
|
|
491
|
+
<input value={urlState.name}
|
|
492
|
+
onChange={(ev) => { setState(curr => ({ ...curr, name: ev.target.value })) }}
|
|
493
|
+
// Можно обновить состояние сразу, но синхронизировать изменения с URL по мере необходимости
|
|
494
|
+
onBlur={() => setUrl()}
|
|
495
|
+
/>
|
|
496
|
+
</div>
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const tags = [
|
|
501
|
+
{
|
|
502
|
+
id: '1',
|
|
503
|
+
value: { text: 'React.js', time: new Date('2024-07-17T04:53:17.000Z') },
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
id: '2',
|
|
507
|
+
value: { text: 'Next.js', time: new Date('2024-07-18T04:53:17.000Z') },
|
|
508
|
+
},
|
|
509
|
+
{
|
|
510
|
+
id: '3',
|
|
511
|
+
value: { text: 'TailwindCSS', time: new Date('2024-07-19T04:53:17.000Z') },
|
|
512
|
+
},
|
|
513
|
+
];
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
[Пример кода](packages/example-react-router6/src/Form-for-test.tsx)
|
|
517
|
+
|
|
518
|
+
## Рецепты
|
|
519
|
+
##### Пользовательский hook для удобной работы со срезом состояния
|
|
520
|
+
<details>
|
|
521
|
+
<Summary>Пример</Summary>
|
|
522
|
+
|
|
523
|
+
```typescript
|
|
524
|
+
'use client';
|
|
525
|
+
|
|
526
|
+
import React from 'react';
|
|
527
|
+
import { useUrlState } from 'state-in-url/next';
|
|
528
|
+
|
|
529
|
+
const form: Form = {
|
|
530
|
+
name: '',
|
|
531
|
+
age: undefined,
|
|
532
|
+
agree_to_terms: false,
|
|
533
|
+
tags: [],
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
type Form = {
|
|
537
|
+
name: string;
|
|
538
|
+
age?: number;
|
|
539
|
+
agree_to_terms: boolean;
|
|
540
|
+
tags: {id: string; value: {text: string; time: Date } }[];
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
export const useFormState = ({ searchParams }: { searchParams?: object }) => {
|
|
544
|
+
const { urlState, setUrl: setUrlBase, reset } = useUrlState(form, {
|
|
545
|
+
searchParams,
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
// первая навигация добавит новую запись в историю
|
|
549
|
+
// все последующие будут лишь заменять эту запись
|
|
550
|
+
// так в истории будет только 2 записи - ['/url', '/url?key=param']
|
|
551
|
+
|
|
552
|
+
const replace = React.useRef(false);
|
|
553
|
+
const setUrl = React.useCallback((
|
|
554
|
+
state: Parameters<typeof setUrlBase>[0],
|
|
555
|
+
opts?: Parameters<typeof setUrlBase>[1]
|
|
556
|
+
) => {
|
|
557
|
+
setUrlBase(state, { replace: replace.current, ...opts });
|
|
558
|
+
replace.current = true;
|
|
559
|
+
}, [setUrlBase]);
|
|
560
|
+
|
|
561
|
+
return { urlState, setUrl, resetUrl: reset };
|
|
562
|
+
};
|
|
563
|
+
```
|
|
564
|
+
</details>
|
|
565
|
+
|
|
566
|
+
<hr />
|
|
567
|
+
|
|
568
|
+
##### Со сложной формой состояния
|
|
569
|
+
|
|
570
|
+
<details>
|
|
571
|
+
<Summary>Пример</Summary>
|
|
572
|
+
|
|
573
|
+
```typescript
|
|
574
|
+
export const form: Form = {
|
|
575
|
+
name: '',
|
|
576
|
+
age: undefined,
|
|
577
|
+
agree_to_terms: false,
|
|
578
|
+
tags: [],
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
type Form = {
|
|
582
|
+
name: string;
|
|
583
|
+
age?: number;
|
|
584
|
+
agree_to_terms: boolean;
|
|
585
|
+
tags: { id: string; value: { text: string; time: Date } }[];
|
|
586
|
+
};
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
```typescript
|
|
590
|
+
'use client'
|
|
591
|
+
import { useUrlState } from 'state-in-url/next';
|
|
592
|
+
|
|
593
|
+
import { form } from './form';
|
|
594
|
+
|
|
595
|
+
function TagsComponent() {
|
|
596
|
+
// `urlState` будет выведен из типа Form!
|
|
597
|
+
const { urlState, setUrl } = useUrlState(form);
|
|
598
|
+
|
|
599
|
+
const onChangeTags = React.useCallback(
|
|
600
|
+
(tag: (typeof tags)[number]) => {
|
|
601
|
+
setUrl((curr) => ({
|
|
602
|
+
...curr,
|
|
603
|
+
tags: curr.tags.find((t) => t.id === tag.id)
|
|
604
|
+
? curr.tags.filter((t) => t.id !== tag.id)
|
|
605
|
+
: curr.tags.concat(tag),
|
|
606
|
+
}));
|
|
607
|
+
},
|
|
608
|
+
[setUrl],
|
|
609
|
+
);
|
|
610
|
+
|
|
611
|
+
return (
|
|
612
|
+
<div>
|
|
613
|
+
<Field text="Tags">
|
|
614
|
+
<div className="flex flex-wrap gap-2">
|
|
615
|
+
{tags.map((tag) => (
|
|
616
|
+
<Tag
|
|
617
|
+
active={!!urlState.tags.find((t) => t.id === tag.id)}
|
|
618
|
+
text={tag.value.text}
|
|
619
|
+
onClick={() => onChangeTags(tag)}
|
|
620
|
+
key={tag.id}
|
|
621
|
+
/>
|
|
622
|
+
))}
|
|
623
|
+
</div>
|
|
624
|
+
</Field>
|
|
625
|
+
</div>
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const tags = [
|
|
630
|
+
{
|
|
631
|
+
id: '1',
|
|
632
|
+
value: { text: 'React.js', time: new Date('2024-07-17T04:53:17.000Z') },
|
|
633
|
+
},
|
|
634
|
+
{
|
|
635
|
+
id: '2',
|
|
636
|
+
value: { text: 'Next.js', time: new Date('2024-07-18T04:53:17.000Z') },
|
|
637
|
+
},
|
|
638
|
+
{
|
|
639
|
+
id: '3',
|
|
640
|
+
value: { text: 'TailwindCSS', time: new Date('2024-07-19T04:53:17.000Z') },
|
|
641
|
+
},
|
|
642
|
+
];
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
[Пример кода демо-страницы](https://github.com/asmyshlyaev177/state-in-url/blob/master/packages/example-nextjs15/src/app/Form.tsx)
|
|
646
|
+
</details>
|
|
647
|
+
|
|
648
|
+
##### Обновлять только состояние и синхронизировать с URL вручную
|
|
649
|
+
|
|
650
|
+
<details>
|
|
651
|
+
<Summary>Пример</Summary>
|
|
652
|
+
|
|
653
|
+
```typescript
|
|
654
|
+
|
|
655
|
+
const timer = React.useRef(0 as unknown as NodeJS.Timeout);
|
|
656
|
+
React.useEffect(() => {
|
|
657
|
+
clearTimeout(timer.current);
|
|
658
|
+
timer.current = setTimeout(() => {
|
|
659
|
+
// сравнит состояние по содержимому, а не по ссылке, и вызовет обновление только для новых значений
|
|
660
|
+
setUrl(urlState);
|
|
661
|
+
}, 500);
|
|
662
|
+
|
|
663
|
+
return () => {
|
|
664
|
+
clearTimeout(timer.current);
|
|
665
|
+
};
|
|
666
|
+
}, [urlState, setUrl]);
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
Синхронизация состояния по `onBlur` лучше соответствует реальному использованию.
|
|
670
|
+
|
|
671
|
+
```typescript
|
|
672
|
+
<input onBlur={() => updateUrl()} .../>
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
</details>
|
|
676
|
+
|
|
677
|
+
## Другие hook'и и вспомогательные функции
|
|
678
|
+
|
|
679
|
+
### Hook `useUrlStateBase` для других роутеров
|
|
680
|
+
|
|
681
|
+
Hook'и для создания собственных hook'ов `useUrlState` с другими роутерами, например react-router или tanstack router.
|
|
682
|
+
|
|
683
|
+
[Документация по API](packages/urlstate/useUrlStateBase)
|
|
684
|
+
|
|
685
|
+
### Hook `useSharedState` для React.js
|
|
686
|
+
|
|
687
|
+
Hook для обмена состоянием между любыми React-компонентами; протестирован с Next.js и Vite.
|
|
688
|
+
|
|
689
|
+
```typescript
|
|
690
|
+
'use client'
|
|
691
|
+
import { useSharedState } from 'state-in-url';
|
|
692
|
+
|
|
693
|
+
export const someState = { name: '' };
|
|
694
|
+
|
|
695
|
+
function SettingsComponent() {
|
|
696
|
+
const { state, setState } = useSharedState(someState);
|
|
697
|
+
}
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
[Документация по API](packages/urlstate/useSharedState/README.ru.md)
|
|
701
|
+
|
|
702
|
+
### Hook `useLinkProps` для React.js
|
|
703
|
+
|
|
704
|
+
Хук, переносящий состояние на ссылку, которая ведёт на другой маршрут, — например, в переключателе языка. `setUrl` всегда пишет в текущий путь, этот хук — нет.
|
|
705
|
+
|
|
706
|
+
```tsx
|
|
707
|
+
'use client'
|
|
708
|
+
import Link from 'next/link';
|
|
709
|
+
import { useRouter } from 'next/navigation';
|
|
710
|
+
import { useLinkProps } from 'state-in-url/useLinkProps';
|
|
711
|
+
|
|
712
|
+
export const form = { name: '' };
|
|
713
|
+
|
|
714
|
+
function LanguagePicker() {
|
|
715
|
+
const linkProps = useLinkProps(form, useRouter().push);
|
|
716
|
+
|
|
717
|
+
return <Link {...linkProps('/de/pricing')}>Deutsch</Link>;
|
|
718
|
+
}
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
В разметке остаётся обычный `href`, поэтому краулеры и `hreflang` видят канонический URL; состояние читается в момент клика.
|
|
722
|
+
|
|
723
|
+
[Документация по API](packages/urlstate/useLinkProps/README.ru.md)
|
|
724
|
+
|
|
725
|
+
### Hook `useUrlEncode` для React.js
|
|
726
|
+
|
|
727
|
+
[Документация по API](packages/urlstate/useUrlEncode/README.ru.md)
|
|
728
|
+
|
|
729
|
+
### Вспомогательные функции `encodeState` и `decodeState`
|
|
730
|
+
|
|
731
|
+
[Документация по API](packages/urlstate/encodeState/README.ru.md)
|
|
732
|
+
|
|
733
|
+
### Вспомогательные функции `encode` и `decode`
|
|
734
|
+
|
|
735
|
+
[Документация по API](packages/urlstate/encoder/README.ru.md)
|
|
736
|
+
|
|
737
|
+
## Рекомендации
|
|
738
|
+
|
|
739
|
+
- Определяйте форму состояния как константу
|
|
740
|
+
- Используйте TypeScript для усиленной типобезопасности и автодополнения
|
|
741
|
+
- Не храните в параметрах URL конфиденциальные данные (SSN, ключи API и т. п.)
|
|
742
|
+
- Используйте это [расширение](https://marketplace.visualstudio.com/items?itemName=yoavbls.pretty-ts-errors) для читаемых ошибок TS
|
|
743
|
+
|
|
744
|
+
Можно создавать hook'и состояния для срезов состояния и переиспользовать их по всему приложению. Например:
|
|
745
|
+
```Typescript
|
|
746
|
+
type UserState = {
|
|
747
|
+
name: string;
|
|
748
|
+
age: number;
|
|
749
|
+
other: { id: string, value: number }[]
|
|
750
|
+
};
|
|
751
|
+
const userState = {
|
|
752
|
+
name: '',
|
|
753
|
+
age: 0,
|
|
754
|
+
other: [],
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
export const useUserState = () => {
|
|
758
|
+
const { urlState, setUrl, reset } = useUrlState(userState);
|
|
759
|
+
|
|
760
|
+
// прочая логика
|
|
761
|
+
|
|
762
|
+
// сбросить параметры запроса при переходе на другую страницу
|
|
763
|
+
React.useEffect(() => {
|
|
764
|
+
return reset
|
|
765
|
+
}, [])
|
|
766
|
+
|
|
767
|
+
return { userState: urlState, setUserState: setUrl };;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
## Подводные камни
|
|
773
|
+
|
|
774
|
+
1. Можно передавать только сериализуемые значения: `Function`, `BigInt` или `Symbol` не сработают, как и, вероятно, `ArrayBuffer`. Всё, что можно сериализовать в JSON, будет работать.
|
|
775
|
+
2. Серверы Vercel ограничивают размер заголовков (строка запроса и прочее) **14KB**, поэтому держите состояние в URL в пределах ~5000 слов. <https://vercel.com/docs/errors/URL_TOO_LONG>
|
|
776
|
+
3. Протестировано с `next.js` 14/15/16 с app router; поддержка pages не планируется.
|
|
777
|
+
|
|
778
|
+
## Прочее
|
|
779
|
+
|
|
780
|
+
### Участие и/или локальный запуск
|
|
781
|
+
|
|
782
|
+
См. [документ по участию](CONTRIBUTING.md)
|
|
783
|
+
|
|
784
|
+
## Дорожная карта
|
|
785
|
+
|
|
786
|
+
- [x] hook для `Next.js`
|
|
787
|
+
- [x] hook для `react-router`
|
|
788
|
+
- [x] hook для `remix`
|
|
789
|
+
- [ ] hook для `svelte`
|
|
790
|
+
- [ ] hook для `astro`
|
|
791
|
+
- [ ] hook для хранения состояния в hash ?
|
|
792
|
+
|
|
793
|
+
## Контакты и поддержка
|
|
794
|
+
|
|
795
|
+
- Создайте [GitHub issue](https://github.com/asmyshlyaev177/state-in-url/issues) для сообщений об ошибках, запросов функций или вопросов
|
|
796
|
+
|
|
797
|
+
## [История изменений](CHANGELOG.md)
|
|
798
|
+
|
|
799
|
+
## Упоминания
|
|
800
|
+
|
|
801
|
+
- [This Week in React 209](https://thisweekinreact.com/newsletter/209)
|
|
802
|
+
- [JavaScript Weekly](https://javascriptweekly.com/issues/741)
|
|
803
|
+
- [This Week in React 240](https://thisweekinreact.com/newsletter/240)
|
|
804
|
+
|
|
805
|
+
## Лицензия
|
|
806
|
+
|
|
807
|
+
Проект распространяется по [лицензии MIT](LICENSE).
|
|
808
|
+
|
|
809
|
+
## Наймите меня
|
|
810
|
+
|
|
811
|
+
Я — **Aleksandr Smyshliaev**, единственный автор и мейнтейнер этой библиотеки.
|
|
812
|
+
Сеньор фронтенд-инженер (React / Next.js / TypeScript, 8+ лет), **готов к полной удалённой работе прямо сейчас**.
|
|
813
|
+
|
|
814
|
+
Эта библиотека — краткая версия того, что я умею: типизированный API поверх неаккуратного браузерного примитива, ноль зависимостей и стабильность в Next.js, Remix и React Router на протяжении нескольких мажорных версий React.
|
|
815
|
+
|
|
816
|
+
- **Сильнее всего в** — библиотеки компонентов, управление состоянием и тестовые наборы, переживающие рефакторинг.
|
|
817
|
+
- **Также моё** —
|
|
818
|
+
[react-horizontal-scrolling-menu](https://github.com/asmyshlyaev177/react-horizontal-scrolling-menu)
|
|
819
|
+
(~84k установок в неделю),
|
|
820
|
+
[test-proxy-recorder](https://github.com/asmyshlyaev177/test-proxy-recorder)
|
|
821
|
+
(запись/воспроизведение для Playwright),
|
|
822
|
+
[llm-queue](https://github.com/asmyshlyaev177/llm-queue).
|
|
823
|
+
- **Где** — Тбилиси, Грузия (GMT+4), полное пересечение с CET. Зарегистрирован как подрядчик, поэтому для B2B-сотрудничества не нужен employer-of-record.
|
|
824
|
+
- **Связаться со мной** — [asmyshlyaev177.dev](https://asmyshlyaev177.dev) ·
|
|
825
|
+
[asmyshlyaev177@gmail.com](mailto:asmyshlyaev177@gmail.com) ·
|
|
826
|
+
[LinkedIn](https://linkedin.com/in/asmyshlyaev177) · Telegram @asmyshlyaev177
|
|
827
|
+
|
|
828
|
+
## Вдохновение
|
|
829
|
+
|
|
830
|
+
[NUQS](https://github.com/47ng/nuqs)
|
|
831
|
+
|
|
832
|
+
[Хранение состояния в URL во Vue](https://dev.to/jacobandrewsky/using-url-to-store-state-in-vue-275c)
|
|
833
|
+
|
|
834
|
+
[Хранение состояния в URL](https://antonz.org/storing-state/)
|
|
835
|
+
|
|
836
|
+
[NextJS useSearchParams](https://nextjs.org/docs/app/api-reference/functions/use-search-params)
|