state-in-url 6.1.3 → 6.2.1
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.md +11 -0
- package/dist/package.json +9 -6
- package/package.json +9 -6
- package/skills/feature-state-hook/SKILL.md +352 -0
- package/skills/form-library-integration/SKILL.md +191 -0
- package/skills/input-handling/SKILL.md +160 -0
- package/skills/nextjs-ssr/SKILL.md +251 -0
- package/skills/react-router-remix-setup/SKILL.md +180 -0
- package/skills/shared-state-no-url/SKILL.md +158 -0
package/README.md
CHANGED
|
@@ -108,6 +108,7 @@ This library is a good alternative for NUQS.
|
|
|
108
108
|
- [installation](#installation)
|
|
109
109
|
- [1. Install package](#1-install-package)
|
|
110
110
|
- [2. Edit tsconfig.json](#2-edit-tsconfigjson)
|
|
111
|
+
- [Using with AI coding agents](#using-with-ai-coding-agents)
|
|
111
112
|
- [useUrlState](#useurlstate)
|
|
112
113
|
- [useUrlState hook for Next.js](#useurlstate-hook-for-nextjs)
|
|
113
114
|
- [Usage examples](#usage-examples)
|
|
@@ -159,6 +160,16 @@ pnpm add state-in-url
|
|
|
159
160
|
In `tsconfig.json` in `compilerOptions` set `"moduleResolution": "Bundler"`, or`"moduleResolution": "Node16"`, or `"moduleResolution": "NodeNext"`.
|
|
160
161
|
Possibly need to set `"module": "ES2022"`, or `"module": "ESNext"`
|
|
161
162
|
|
|
163
|
+
## Using with AI coding agents
|
|
164
|
+
|
|
165
|
+
`state-in-url` ships skill files for [@tanstack/intent](https://tanstack.com/intent/latest/docs/overview), so AI agents (Claude Code, Cursor, Copilot, Codex, etc.) load the right patterns and avoid common mistakes when using the library. After installing `state-in-url`, run once in your project:
|
|
166
|
+
|
|
167
|
+
```sh
|
|
168
|
+
npx @tanstack/intent@latest install
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
This wires your installed agent to discover the skills from `node_modules/state-in-url/skills/`. List available skills with `npx @tanstack/intent@latest list`.
|
|
172
|
+
|
|
162
173
|
## useUrlState
|
|
163
174
|
|
|
164
175
|
Main hook that takes initial state as parameter and returns state object, callback to update url, and callback to update only state.
|
package/dist/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "state-in-url",
|
|
3
|
-
"version": "6.1
|
|
3
|
+
"version": "6.2.1",
|
|
4
4
|
"description": "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.",
|
|
5
5
|
"homepage": "https://state-in-url.dev",
|
|
6
6
|
"repository": {
|
|
@@ -26,7 +26,9 @@
|
|
|
26
26
|
"url": "https://github.com/asmyshlyaev177/state-in-url/issues"
|
|
27
27
|
},
|
|
28
28
|
"files": [
|
|
29
|
-
"dist"
|
|
29
|
+
"dist",
|
|
30
|
+
"skills",
|
|
31
|
+
"!skills/_artifacts"
|
|
30
32
|
],
|
|
31
33
|
"main": "./dist/index.js",
|
|
32
34
|
"module": "dist/index.mjs",
|
|
@@ -439,6 +441,7 @@
|
|
|
439
441
|
},
|
|
440
442
|
"devDependencies": {
|
|
441
443
|
"@babel/core": "^7.28.4",
|
|
444
|
+
"@tanstack/intent": "latest",
|
|
442
445
|
"@babel/node": "^7.28.0",
|
|
443
446
|
"@babel/preset-env": "^7.28.3",
|
|
444
447
|
"@babel/preset-typescript": "^7.27.1",
|
|
@@ -480,7 +483,7 @@
|
|
|
480
483
|
"happy-dom": "^20.0.2",
|
|
481
484
|
"husky": "^9.1.7",
|
|
482
485
|
"lint-staged": "^15.5.2",
|
|
483
|
-
"next": "^15.5.
|
|
486
|
+
"next": "^15.5.19",
|
|
484
487
|
"nyc": "^17.1.0",
|
|
485
488
|
"only-allow": "^1.2.1",
|
|
486
489
|
"pin-github-action": "^1.9.1",
|
|
@@ -489,8 +492,7 @@
|
|
|
489
492
|
"prettier-plugin-tailwindcss": "^0.6.14",
|
|
490
493
|
"react": "^19.2.6",
|
|
491
494
|
"react-dom": "^19.2.6",
|
|
492
|
-
"react-router": "^7.
|
|
493
|
-
"ringfence": "^0.1.0",
|
|
495
|
+
"react-router": "^7.17.0",
|
|
494
496
|
"rollup": "^4.52.3",
|
|
495
497
|
"rollup-plugin-esbuild": "^6.2.1",
|
|
496
498
|
"rollup-plugin-filesize": "^10.0.0",
|
|
@@ -557,7 +559,8 @@
|
|
|
557
559
|
"search",
|
|
558
560
|
"encode url",
|
|
559
561
|
"decode url",
|
|
560
|
-
"fast"
|
|
562
|
+
"fast",
|
|
563
|
+
"tanstack-intent"
|
|
561
564
|
],
|
|
562
565
|
"packageManager": "pnpm@10.26.0",
|
|
563
566
|
"pnpm": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "state-in-url",
|
|
3
|
-
"version": "6.1
|
|
3
|
+
"version": "6.2.1",
|
|
4
4
|
"description": "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.",
|
|
5
5
|
"homepage": "https://state-in-url.dev",
|
|
6
6
|
"repository": {
|
|
@@ -26,7 +26,9 @@
|
|
|
26
26
|
"url": "https://github.com/asmyshlyaev177/state-in-url/issues"
|
|
27
27
|
},
|
|
28
28
|
"files": [
|
|
29
|
-
"dist"
|
|
29
|
+
"dist",
|
|
30
|
+
"skills",
|
|
31
|
+
"!skills/_artifacts"
|
|
30
32
|
],
|
|
31
33
|
"main": "./dist/index.js",
|
|
32
34
|
"module": "dist/index.mjs",
|
|
@@ -439,6 +441,7 @@
|
|
|
439
441
|
},
|
|
440
442
|
"devDependencies": {
|
|
441
443
|
"@babel/core": "^7.28.4",
|
|
444
|
+
"@tanstack/intent": "latest",
|
|
442
445
|
"@babel/node": "^7.28.0",
|
|
443
446
|
"@babel/preset-env": "^7.28.3",
|
|
444
447
|
"@babel/preset-typescript": "^7.27.1",
|
|
@@ -480,7 +483,7 @@
|
|
|
480
483
|
"happy-dom": "^20.0.2",
|
|
481
484
|
"husky": "^9.1.7",
|
|
482
485
|
"lint-staged": "^15.5.2",
|
|
483
|
-
"next": "^15.5.
|
|
486
|
+
"next": "^15.5.19",
|
|
484
487
|
"nyc": "^17.1.0",
|
|
485
488
|
"only-allow": "^1.2.1",
|
|
486
489
|
"pin-github-action": "^1.9.1",
|
|
@@ -489,8 +492,7 @@
|
|
|
489
492
|
"prettier-plugin-tailwindcss": "^0.6.14",
|
|
490
493
|
"react": "^19.2.6",
|
|
491
494
|
"react-dom": "^19.2.6",
|
|
492
|
-
"react-router": "^7.
|
|
493
|
-
"ringfence": "^0.1.0",
|
|
495
|
+
"react-router": "^7.17.0",
|
|
494
496
|
"rollup": "^4.52.3",
|
|
495
497
|
"rollup-plugin-esbuild": "^6.2.1",
|
|
496
498
|
"rollup-plugin-filesize": "^10.0.0",
|
|
@@ -557,7 +559,8 @@
|
|
|
557
559
|
"search",
|
|
558
560
|
"encode url",
|
|
559
561
|
"decode url",
|
|
560
|
-
"fast"
|
|
562
|
+
"fast",
|
|
563
|
+
"tanstack-intent"
|
|
561
564
|
],
|
|
562
565
|
"packageManager": "pnpm@10.26.0",
|
|
563
566
|
"pnpm": {
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: feature-state-hook
|
|
3
|
+
description: >
|
|
4
|
+
Define typed, module-scoped state and wrap useUrlState in a feature-scoped
|
|
5
|
+
custom hook so unrelated React components share the same URL-synced state.
|
|
6
|
+
Covers drawer/modal open-state, tab switching, multi-select toggles,
|
|
7
|
+
reset/defaults semantics, and the object-identity-based sharing model.
|
|
8
|
+
Load this skill when storing filters, tabs, drawers, selections, paginators,
|
|
9
|
+
or any UI state that should survive reloads and be shareable by URL.
|
|
10
|
+
type: core
|
|
11
|
+
library: state-in-url
|
|
12
|
+
library_version: '6.1.3'
|
|
13
|
+
sources:
|
|
14
|
+
- 'asmyshlyaev177/state-in-url:packages/urlstate/next/useUrlState/useUrlState.ts'
|
|
15
|
+
- 'asmyshlyaev177/state-in-url:packages/urlstate/utils.ts'
|
|
16
|
+
- 'asmyshlyaev177/state-in-url:README.md'
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
# state-in-url — Feature state hook
|
|
20
|
+
|
|
21
|
+
`state-in-url` stores a typed JSON-serializable object in the URL query string. State across components is shared by passing the **same module-scoped default-state object** to `useUrlState`. The library uses object identity, not deep equality, to wire up subscriptions — so the default state must be a static `const`, defined once, outside any component.
|
|
22
|
+
|
|
23
|
+
## Setup
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
// features/jobs/jobsState.ts
|
|
27
|
+
export type JobsState = {
|
|
28
|
+
status: '' | 'active' | 'closed';
|
|
29
|
+
tab: 'details' | 'qa' | 'applicants';
|
|
30
|
+
jobId: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const JOBS_STATE: JobsState = {
|
|
34
|
+
status: '',
|
|
35
|
+
tab: 'details',
|
|
36
|
+
jobId: '',
|
|
37
|
+
};
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
// features/jobs/useJobsState.ts
|
|
42
|
+
'use client';
|
|
43
|
+
import { useSearchParams } from 'next/navigation';
|
|
44
|
+
import { useUrlState } from 'state-in-url/next';
|
|
45
|
+
import { JOBS_STATE } from './jobsState';
|
|
46
|
+
|
|
47
|
+
export function useJobsState() {
|
|
48
|
+
const searchParams = useSearchParams();
|
|
49
|
+
return useUrlState(JOBS_STATE, { searchParams });
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
// any component
|
|
55
|
+
import { useJobsState } from 'features/jobs/useJobsState';
|
|
56
|
+
|
|
57
|
+
export function JobsTabs() {
|
|
58
|
+
const { urlState, setUrl, reset } = useJobsState();
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<>
|
|
62
|
+
<button onClick={() => setUrl({ tab: 'qa' })}>Q&A</button>
|
|
63
|
+
<button onClick={reset}>Reset</button>
|
|
64
|
+
</>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The same `useJobsState()` called in two different components reads and writes the same URL state — no Context, no Provider.
|
|
70
|
+
|
|
71
|
+
## Core Patterns
|
|
72
|
+
|
|
73
|
+
### Drawer / modal open-close via URL
|
|
74
|
+
|
|
75
|
+
Pick an empty-string default for the ID field. Empty = closed (and the URL stays clean); non-empty = open.
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
type MembersState = { memberId: string; tab: 'profile' | 'activity' };
|
|
79
|
+
const MEMBERS_STATE: MembersState = { memberId: '', tab: 'profile' };
|
|
80
|
+
|
|
81
|
+
const open = (id: string) => setUrl({ memberId: id, tab: 'profile' });
|
|
82
|
+
const close = () => setUrl({ ...MEMBERS_STATE });
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`setUrl({ ...MEMBERS_STATE })` returns every field to default → all related URL params disappear in one call.
|
|
86
|
+
|
|
87
|
+
### Multi-select toggle (functional update)
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
const toggle = (id: string) =>
|
|
91
|
+
setUrl((curr) => ({
|
|
92
|
+
...curr,
|
|
93
|
+
tags: curr.tags.includes(id)
|
|
94
|
+
? curr.tags.filter((t) => t !== id)
|
|
95
|
+
: curr.tags.concat(id),
|
|
96
|
+
}));
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Reset to defaults
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
<button onClick={reset}>Reset</button>
|
|
103
|
+
// or, equivalently:
|
|
104
|
+
<button onClick={() => setUrl((_, initial) => initial)}>Reset</button>
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Multiple independent state objects on one page
|
|
108
|
+
|
|
109
|
+
Different default-state objects → independent stores. Choose non-overlapping top-level field names.
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
type FiltersState = { search: string; sortBy: 'name' | 'date' };
|
|
113
|
+
type DrawerState = { open: boolean; view: 'profile' | 'settings' };
|
|
114
|
+
|
|
115
|
+
const FILTERS_STATE: FiltersState = { search: '', sortBy: 'name' };
|
|
116
|
+
const DRAWER_STATE: DrawerState = { open: false, view: 'profile' };
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Common Mistakes
|
|
120
|
+
|
|
121
|
+
### CRITICAL defaultState defined inside the React component
|
|
122
|
+
|
|
123
|
+
Wrong:
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
function MyFeature({ initialTab }: Props) {
|
|
127
|
+
const defaults = { tab: initialTab, open: false }; // recreated every render
|
|
128
|
+
const { urlState } = useUrlState(defaults);
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Correct:
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
type FeatureState = { tab: 'a' | 'b'; open: boolean };
|
|
136
|
+
const FEATURE_STATE: FeatureState = { tab: 'a', open: false };
|
|
137
|
+
|
|
138
|
+
function MyFeature() {
|
|
139
|
+
const { urlState } = useUrlState(FEATURE_STATE);
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The library uses object identity of the default-state argument to wire subscriptions and seed initial state. A new object on every render breaks sharing, breaks SSR hydration, and silently loses URL values on first paint. Maintainer-confirmed on issues #57, #60, #69.
|
|
144
|
+
|
|
145
|
+
Source: GitHub issues #57, #60, #69 (asmyshlyaev177/state-in-url)
|
|
146
|
+
|
|
147
|
+
### CRITICAL Using `interface` instead of `type` for the state shape
|
|
148
|
+
|
|
149
|
+
Wrong:
|
|
150
|
+
|
|
151
|
+
```typescript
|
|
152
|
+
interface FeatureState { tab: string; open: boolean }
|
|
153
|
+
const initial: FeatureState = { tab: 'a', open: false };
|
|
154
|
+
useUrlState(initial); // TS error on JSONCompatible<T>
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Correct:
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
type FeatureState = { tab: string; open: boolean };
|
|
161
|
+
const initial: FeatureState = { tab: 'a', open: false };
|
|
162
|
+
useUrlState(initial);
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
The hook's generic constraint `JSONCompatible<T>` accepts `type` aliases but rejects `interface` declarations due to how TypeScript handles index signatures in mapped types. **Always** declare an explicit `type` for the state shape and annotate the default-state const with it (`const FOO_STATE: FooState = { ... }`) — don't rely on inferred types from a plain `const`, since narrowing surprises (`tab: 'a'` inferred as the literal `'a'`, not the union) lead to confusing type errors at every `setUrl` call site.
|
|
166
|
+
|
|
167
|
+
Source: GitHub issue #21 (asmyshlyaev177/state-in-url)
|
|
168
|
+
|
|
169
|
+
### CRITICAL `setUrl` inside `useEffect` → infinite update loop
|
|
170
|
+
|
|
171
|
+
Wrong:
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
React.useEffect(() => {
|
|
175
|
+
setUrl({ tab: urlState.tab.toLowerCase() }); // re-fires effect
|
|
176
|
+
}, [urlState, setUrl]);
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Correct:
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
// Derive on read instead
|
|
183
|
+
const tab = urlState.tab.toLowerCase();
|
|
184
|
+
|
|
185
|
+
// Or, if a sync truly must happen, gate on the actual change
|
|
186
|
+
React.useEffect(() => {
|
|
187
|
+
const lower = urlState.tab.toLowerCase();
|
|
188
|
+
if (urlState.tab !== lower) setUrl({ tab: lower });
|
|
189
|
+
}, [urlState.tab, setUrl]);
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
URL throttling does not break a state→effect→setUrl→state cycle. The state updates first, the effect re-fires, repeat.
|
|
193
|
+
|
|
194
|
+
Source: Maintainer interview
|
|
195
|
+
|
|
196
|
+
### HIGH Calling `useUrlState` directly with separate default objects in N components
|
|
197
|
+
|
|
198
|
+
Wrong:
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
// ComponentA.tsx
|
|
202
|
+
const DEFAULTS = { tab: 'a' };
|
|
203
|
+
const { urlState } = useUrlState(DEFAULTS);
|
|
204
|
+
|
|
205
|
+
// ComponentB.tsx (different file → different identity)
|
|
206
|
+
const DEFAULTS = { tab: 'a' };
|
|
207
|
+
const { urlState } = useUrlState(DEFAULTS); // not sharing with A
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Correct:
|
|
211
|
+
|
|
212
|
+
```typescript
|
|
213
|
+
// hooks/useFeatureState.ts — one source of truth
|
|
214
|
+
export type FeatureState = { tab: 'a' | 'b' };
|
|
215
|
+
export const FEATURE_STATE: FeatureState = { tab: 'a' };
|
|
216
|
+
export const useFeatureState = () => useUrlState(FEATURE_STATE);
|
|
217
|
+
|
|
218
|
+
// every component imports the hook, never useUrlState directly
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Sharing is keyed by default-state object identity. Two components each declaring their own `const DEFAULTS = {...}` produce two independent stores even with identical shape.
|
|
222
|
+
|
|
223
|
+
Source: Maintainer interview — highest-impact production hazard
|
|
224
|
+
|
|
225
|
+
### HIGH `setUrl` or `setState` called during render
|
|
226
|
+
|
|
227
|
+
Wrong:
|
|
228
|
+
|
|
229
|
+
```typescript
|
|
230
|
+
function Component() {
|
|
231
|
+
const { urlState, setUrl } = useFeatureState();
|
|
232
|
+
if (!urlState.initialized) setUrl({ initialized: true });
|
|
233
|
+
return <div>...</div>;
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Correct:
|
|
238
|
+
|
|
239
|
+
```typescript
|
|
240
|
+
function Component() {
|
|
241
|
+
const { urlState, setUrl } = useFeatureState();
|
|
242
|
+
React.useEffect(() => {
|
|
243
|
+
if (!urlState.initialized) setUrl({ initialized: true });
|
|
244
|
+
}, [urlState.initialized, setUrl]);
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
State setters must run in effects or handlers, never during render. React surfaces this with "Cannot update a component while rendering a different component."
|
|
249
|
+
|
|
250
|
+
Source: Maintainer interview
|
|
251
|
+
|
|
252
|
+
### HIGH Storing non-JSON-serializable values
|
|
253
|
+
|
|
254
|
+
Wrong:
|
|
255
|
+
|
|
256
|
+
```typescript
|
|
257
|
+
const STATE = { onChange: () => {}, items: new Set([1, 2]) };
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Correct:
|
|
261
|
+
|
|
262
|
+
```typescript
|
|
263
|
+
const STATE = { items: [1, 2] as number[], updatedAt: new Date() };
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Functions, Symbols, BigInt, Map, Set, ArrayBuffer, and class instances are not JSON-serializable and won't round-trip. Dates **are** supported (the encoder has special-case handling).
|
|
267
|
+
|
|
268
|
+
Source: packages/urlstate/utils.ts (JSONCompatible type); README Gotchas
|
|
269
|
+
|
|
270
|
+
### MEDIUM Mutating `urlState` directly
|
|
271
|
+
|
|
272
|
+
Wrong:
|
|
273
|
+
|
|
274
|
+
```typescript
|
|
275
|
+
urlState.tab = 'b'; // no-op
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Correct:
|
|
279
|
+
|
|
280
|
+
```typescript
|
|
281
|
+
setUrl({ tab: 'b' });
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
`urlState` is a reference to internal state; mutating it bypasses subscribers and URL sync.
|
|
285
|
+
|
|
286
|
+
Source: JSDoc on `useUrlState` return type
|
|
287
|
+
|
|
288
|
+
### MEDIUM Expecting reset to keep default-valued fields in the URL
|
|
289
|
+
|
|
290
|
+
Wrong:
|
|
291
|
+
|
|
292
|
+
```typescript
|
|
293
|
+
// Wanting ?tab=features to persist after a reset
|
|
294
|
+
const STATE = { tab: 'features' };
|
|
295
|
+
reset(); // URL becomes clean — no ?tab= at all
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Correct:
|
|
299
|
+
|
|
300
|
+
```typescript
|
|
301
|
+
// Pick a default that means "no selection". The URL stays clean at default;
|
|
302
|
+
// ?tab=qa appears only when the user selects something non-default.
|
|
303
|
+
const STATE = { tab: 'details' | 'qa' | 'applicants' };
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
The library only encodes fields whose value differs from the default — this is what keeps URLs short.
|
|
307
|
+
|
|
308
|
+
Source: README "Best Practices"
|
|
309
|
+
|
|
310
|
+
### MEDIUM Namespace collision between two features
|
|
311
|
+
|
|
312
|
+
Wrong:
|
|
313
|
+
|
|
314
|
+
```typescript
|
|
315
|
+
const JOBS_STATE = { tab: 'details' };
|
|
316
|
+
const SETTINGS_STATE = { tab: 'profile' };
|
|
317
|
+
// both mounted on same page → fight over ?tab=
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Correct:
|
|
321
|
+
|
|
322
|
+
```typescript
|
|
323
|
+
type JobsState = { jobs_tab: 'details' | 'qa' | 'applicants' };
|
|
324
|
+
type SettingsState = { settings_tab: 'profile' | 'account' };
|
|
325
|
+
|
|
326
|
+
const JOBS_STATE: JobsState = { jobs_tab: 'details' };
|
|
327
|
+
const SETTINGS_STATE: SettingsState = { settings_tab: 'profile' };
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
Each `useUrlState` instance reads/writes its keys against the global query string. Two features defining the same field name overwrite each other.
|
|
331
|
+
|
|
332
|
+
Source: Maintainer interview
|
|
333
|
+
|
|
334
|
+
## Sensitive data
|
|
335
|
+
|
|
336
|
+
Entity IDs (`jobId`, `memberId`, `channelId`) referencing public or semi-public DB rows are fine — they already appear in route paths and have no secrecy expectation. Never put **true secrets** in the URL: auth tokens, API keys, passwords, PII (email, SSN, phone).
|
|
337
|
+
|
|
338
|
+
## Other primitives — when to reach for them
|
|
339
|
+
|
|
340
|
+
- **`useSharedState`** — cross-component state without URL sync. See `state-in-url/shared-state-no-url`.
|
|
341
|
+
- **`encodeState` / `decodeState`** — server-side or Node.js encoding/decoding of state strings (e.g. inside Next.js Proxy or a layout). Imported from `state-in-url/encodeState`.
|
|
342
|
+
- **`useUrlStateBase`** — build a `useUrlState` for an unsupported router (TanStack Router etc.). Imported from `state-in-url/useUrlStateBase`.
|
|
343
|
+
|
|
344
|
+
## URL size
|
|
345
|
+
|
|
346
|
+
Keep total query-string size well under ~12 KB to stay safe across CDNs and Vercel's 14 KB header limit. See [Limits.md](https://github.com/asmyshlyaev177/state-in-url/blob/master/Limits.md).
|
|
347
|
+
|
|
348
|
+
## See also
|
|
349
|
+
|
|
350
|
+
- `state-in-url/input-handling` — pattern for text inputs and sliders (instant `setState`, deferred `setUrl`).
|
|
351
|
+
- `state-in-url/nextjs-ssr` — required when using this skill in Next.js to avoid hydration mismatches.
|
|
352
|
+
- `state-in-url/form-library-integration` — when the feature is a `react-hook-form` form.
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: form-library-integration
|
|
3
|
+
description: >
|
|
4
|
+
Use the URL as a form draft store by pairing useUrlState with
|
|
5
|
+
react-hook-form (or formik). Share one module-scoped defaults object
|
|
6
|
+
between both libraries; hydrate useForm.defaultValues from urlState;
|
|
7
|
+
push form changes back into setUrl via RHF subscribe() (NOT watch()).
|
|
8
|
+
Load this skill when a form should round-trip its values through the
|
|
9
|
+
URL — filter forms, search forms, multi-step wizards with shareable
|
|
10
|
+
draft links.
|
|
11
|
+
type: composition
|
|
12
|
+
library: state-in-url
|
|
13
|
+
library_version: '6.1.3'
|
|
14
|
+
requires:
|
|
15
|
+
- feature-state-hook
|
|
16
|
+
sources:
|
|
17
|
+
- 'asmyshlyaev177/state-in-url:issue#57'
|
|
18
|
+
- 'asmyshlyaev177/state-in-url:README.md'
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
This skill builds on `state-in-url/feature-state-hook`. Read it first for the module-scoped default-state rule.
|
|
22
|
+
|
|
23
|
+
# state-in-url — Form library integration (react-hook-form)
|
|
24
|
+
|
|
25
|
+
The canonical pattern: one module-scoped defaults object owns the form shape, both `useUrlState` and `useForm` consume it, RHF's `subscribe()` callback pushes form changes into `setUrl`. Hydration from URL happens through `defaultValues: urlState`.
|
|
26
|
+
|
|
27
|
+
## Setup
|
|
28
|
+
|
|
29
|
+
```typescript
|
|
30
|
+
// features/filters/filtersState.ts
|
|
31
|
+
import { z } from 'zod';
|
|
32
|
+
|
|
33
|
+
export const filtersSchema = z.object({
|
|
34
|
+
q: z.string(),
|
|
35
|
+
sort: z.enum(['name', 'date']),
|
|
36
|
+
tags: z.array(z.string()),
|
|
37
|
+
});
|
|
38
|
+
export type FiltersState = z.infer<typeof filtersSchema>;
|
|
39
|
+
|
|
40
|
+
export const FILTERS_STATE: FiltersState = {
|
|
41
|
+
q: '',
|
|
42
|
+
sort: 'name',
|
|
43
|
+
tags: [],
|
|
44
|
+
};
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```typescript
|
|
48
|
+
// features/filters/FiltersForm.tsx
|
|
49
|
+
'use client';
|
|
50
|
+
import React from 'react';
|
|
51
|
+
import { useSearchParams } from 'next/navigation';
|
|
52
|
+
import { useForm } from 'react-hook-form';
|
|
53
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
54
|
+
import { useUrlState } from 'state-in-url/next';
|
|
55
|
+
import { FILTERS_STATE, filtersSchema, type FiltersState } from './filtersState';
|
|
56
|
+
|
|
57
|
+
export function FiltersForm() {
|
|
58
|
+
const searchParams = useSearchParams();
|
|
59
|
+
const { urlState, setUrl } = useUrlState(FILTERS_STATE, { searchParams });
|
|
60
|
+
|
|
61
|
+
const form = useForm<FiltersState>({
|
|
62
|
+
resolver: zodResolver(filtersSchema),
|
|
63
|
+
defaultValues: urlState, // hydrate from URL on mount
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
React.useEffect(() => {
|
|
67
|
+
const sub = form.subscribe({
|
|
68
|
+
formState: { values: true },
|
|
69
|
+
callback: ({ values }) => setUrl(values),
|
|
70
|
+
});
|
|
71
|
+
return () => sub();
|
|
72
|
+
}, [form, setUrl]);
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<form>
|
|
76
|
+
<input {...form.register('q')} />
|
|
77
|
+
<select {...form.register('sort')}>
|
|
78
|
+
<option value="name">Name</option>
|
|
79
|
+
<option value="date">Date</option>
|
|
80
|
+
</select>
|
|
81
|
+
</form>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Core Patterns
|
|
87
|
+
|
|
88
|
+
### Hydrate `useForm` from `urlState`
|
|
89
|
+
|
|
90
|
+
`defaultValues: urlState` runs once on mount; the form is initialized with whatever the URL contained (or `FILTERS_STATE` defaults if URL is empty).
|
|
91
|
+
|
|
92
|
+
### Push form → URL with `subscribe()`
|
|
93
|
+
|
|
94
|
+
RHF's `subscribe()` is push-based — it fires the callback without re-rendering the host component. Inside, call `setUrl(values)` to coalesce the change into the URL (the library content-diffs internally so no-ops are free).
|
|
95
|
+
|
|
96
|
+
### Reset
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
const onReset = () => {
|
|
100
|
+
form.reset(FILTERS_STATE); // reset form
|
|
101
|
+
setUrl((_, initial) => initial); // reset URL
|
|
102
|
+
};
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Common Mistakes
|
|
106
|
+
|
|
107
|
+
### HIGH Building `defaultValues` from `urlState` inline (not the module-scoped shape)
|
|
108
|
+
|
|
109
|
+
Wrong:
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
const defaults = { ...formValues, ...overrides }; // new object each render
|
|
113
|
+
const { urlState } = useUrlState(defaults);
|
|
114
|
+
const form = useForm({ defaultValues: defaults });
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Correct:
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
// One module-scoped const, shared by both hooks
|
|
121
|
+
export const FILTERS_STATE: FiltersState = { q: '', sort: 'name', tags: [] };
|
|
122
|
+
|
|
123
|
+
const { urlState } = useUrlState(FILTERS_STATE, { searchParams });
|
|
124
|
+
const form = useForm({ defaultValues: urlState }); // hydrate from URL
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
A fresh `defaults` on each render breaks `useUrlState`'s identity-based sharing (same root cause as the cross-skill failure). Use the module-scoped shape for `useUrlState`; pass `urlState` (the live value) to `useForm`.
|
|
128
|
+
|
|
129
|
+
Source: GitHub issue #57 (asmyshlyaev177/state-in-url, maintainer reply)
|
|
130
|
+
|
|
131
|
+
### MEDIUM Using `watch()` instead of `subscribe()` to sync RHF → URL
|
|
132
|
+
|
|
133
|
+
Wrong:
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
const values = form.watch();
|
|
137
|
+
React.useEffect(() => {
|
|
138
|
+
setUrl(values);
|
|
139
|
+
}, [values, setUrl]);
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Correct:
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
React.useEffect(() => {
|
|
146
|
+
const sub = form.subscribe({
|
|
147
|
+
formState: { values: true },
|
|
148
|
+
callback: ({ values }) => setUrl(values),
|
|
149
|
+
});
|
|
150
|
+
return () => sub();
|
|
151
|
+
}, [form, setUrl]);
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
`watch()` re-renders the host component on every field change. `subscribe()` is push-based and avoids the rerender, which matters in large forms.
|
|
155
|
+
|
|
156
|
+
Source: GitHub issue #57 (asmyshlyaev177/state-in-url, maintainer reply)
|
|
157
|
+
|
|
158
|
+
### CRITICAL `defaultState` defined inside the React component
|
|
159
|
+
|
|
160
|
+
(Cross-skill failure — also in `feature-state-hook`.)
|
|
161
|
+
|
|
162
|
+
Wrong:
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
function FiltersForm({ initialSort }: Props) {
|
|
166
|
+
const defaults = { q: '', sort: initialSort, tags: [] }; // each render
|
|
167
|
+
const { urlState } = useUrlState(defaults);
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Correct: Move the shape to a module-scoped `const` (`FILTERS_STATE`) and import it.
|
|
172
|
+
|
|
173
|
+
Source: GitHub issues #57, #60, #69 (asmyshlyaev177/state-in-url)
|
|
174
|
+
|
|
175
|
+
## formik variant
|
|
176
|
+
|
|
177
|
+
Same idea: subscribe to `formik.values` and call `setUrl`. With formik, use a `useEffect` on `values` since formik does re-render on changes by design.
|
|
178
|
+
|
|
179
|
+
```typescript
|
|
180
|
+
const formik = useFormik({ initialValues: urlState, onSubmit: () => {} });
|
|
181
|
+
|
|
182
|
+
React.useEffect(() => {
|
|
183
|
+
setUrl(formik.values);
|
|
184
|
+
}, [formik.values, setUrl]);
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## See also
|
|
188
|
+
|
|
189
|
+
- `state-in-url/feature-state-hook` — base pattern; required reading.
|
|
190
|
+
- `state-in-url/nextjs-ssr` — for SSR hydration of the filter form on Next.js.
|
|
191
|
+
- `state-in-url/input-handling` — for non-form inputs (search box not inside a form).
|