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.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
+ [![Available for hire](https://img.shields.io/badge/available%20for%20hire-senior%20react%20engineer-2ea44f?style=flat-square)](https://asmyshlyaev177.dev)
18
+
19
+ [![npm](https://img.shields.io/npm/v/state-in-url.svg)](https://www.npmjs.com/package/state-in-url)
20
+ ![npm bundle size (minified + gzip)](https://img.shields.io/bundlephobia/minzip/state-in-url.svg)
21
+ [![Codacy Badge](https://app.codacy.com/project/badge/Grade/73be54068b7f41b0b74a252579ac09ec)](https://app.codacy.com/gh/asmyshlyaev177/state-in-url/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
22
+
23
+ ![Tests](https://github.com/asmyshlyaev177/state-in-url/actions/workflows/tests.yml/badge.svg?branch=master)
24
+ [![Codacy Badge](https://app.codacy.com/project/badge/Coverage/73be54068b7f41b0b74a252579ac09ec)](https://app.codacy.com/gh/asmyshlyaev177/state-in-url/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_coverage)
25
+ [![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](https://github.com/asmyshlyaev177/state-in-url/)
26
+ [![semantic-release: angular](https://img.shields.io/badge/semantic--release-angular-e10079?logo=semantic-release)]([https://github.com/semantic-release/semantic-release](https://github.com/asmyshlyaev177/state-in-url))
27
+
28
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/asmyshlyaev177/state-in-url/badge)](https://scorecard.dev/viewer/?uri=github.com/asmyshlyaev177/state-in-url)
29
+ [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/9679/badge)](https://www.bestpractices.dev/projects/9679)
30
+ [![license](https://img.shields.io/github/license/asmyshlyaev177/state-in-url.svg?style=flat-square)](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
+ ![Demo-gif](https://github.com/asmyshlyaev177/state-in-url/blob/master/assets/Demo-gif.gif)
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)