state-in-url 6.1.3 → 6.2.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.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",
3
+ "version": "6.2.0",
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",
@@ -490,7 +493,6 @@
490
493
  "react": "^19.2.6",
491
494
  "react-dom": "^19.2.6",
492
495
  "react-router": "^7.15.0",
493
- "ringfence": "^0.1.0",
494
496
  "rollup": "^4.52.3",
495
497
  "rollup-plugin-esbuild": "^6.2.1",
496
498
  "rollup-plugin-filesize": "^10.0.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "state-in-url",
3
- "version": "6.1.3",
3
+ "version": "6.2.0",
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",
@@ -490,7 +493,6 @@
490
493
  "react": "^19.2.6",
491
494
  "react-dom": "^19.2.6",
492
495
  "react-router": "^7.15.0",
493
- "ringfence": "^0.1.0",
494
496
  "rollup": "^4.52.3",
495
497
  "rollup-plugin-esbuild": "^6.2.1",
496
498
  "rollup-plugin-filesize": "^10.0.0",
@@ -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).
@@ -0,0 +1,160 @@
1
+ ---
2
+ name: input-handling
3
+ description: >
4
+ Reconcile instant local input feedback with URL write throttling. setState
5
+ updates internal state synchronously (instant re-render); setUrl is throttled
6
+ and asynchronous (URL catches up on the next tick). Use this skill for text
7
+ inputs, search boxes, sliders, range pickers, and any control that fires
8
+ many updates per second where binding setUrl directly to onChange causes
9
+ perceived lag or wasted URL writes.
10
+ type: core
11
+ library: state-in-url
12
+ library_version: '6.1.3'
13
+ requires:
14
+ - feature-state-hook
15
+ sources:
16
+ - 'asmyshlyaev177/state-in-url:packages/urlstate/useUrlStateBase/useUrlStateBase.ts'
17
+ - 'asmyshlyaev177/state-in-url:packages/urlstate/utils.ts'
18
+ - 'asmyshlyaev177/state-in-url:README.md#update-state-only-and-sync-to-url-manually'
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 — Input handling
24
+
25
+ `useUrlState` returns three setters with different timing:
26
+
27
+ | Setter | What updates | When |
28
+ |---|---|---|
29
+ | `setState(value)` | Internal state only | Synchronous |
30
+ | `setUrl(value)` | Internal state + URL | State sync, URL on next tick (throttled) |
31
+ | `setUrl()` | Flush current state to URL | URL on next tick (diff-checked, no-op if equal) |
32
+
33
+ State updates always render immediately. URL writes are coalesced through an internal global timer (`TIMEOUT` constant in [useUrlStateBase.ts](https://github.com/asmyshlyaev177/state-in-url/blob/master/packages/urlstate/useUrlStateBase/useUrlStateBase.ts)) so a burst of `setUrl` calls produces one URL update.
34
+
35
+ ## Setup
36
+
37
+ ```typescript
38
+ // features/search/searchState.ts
39
+ export type SearchState = { q: string; sort: 'name' | 'date' };
40
+ export const SEARCH_STATE: SearchState = { q: '', sort: 'name' };
41
+ ```
42
+
43
+ ```typescript
44
+ // features/search/useSearchState.ts
45
+ 'use client';
46
+ import { useSearchParams } from 'next/navigation';
47
+ import { useUrlState } from 'state-in-url/next';
48
+ import { SEARCH_STATE } from './searchState';
49
+
50
+ export function useSearchState() {
51
+ const searchParams = useSearchParams();
52
+ return useUrlState(SEARCH_STATE, { searchParams });
53
+ }
54
+ ```
55
+
56
+ ## Core Patterns
57
+
58
+ ### Instant input, deferred URL write (onBlur)
59
+
60
+ ```typescript
61
+ function SearchBox() {
62
+ const { urlState, setState, setUrl } = useSearchState();
63
+
64
+ return (
65
+ <input
66
+ value={urlState.q}
67
+ onChange={(e) => setState({ q: e.target.value })}
68
+ onBlur={() => setUrl()}
69
+ />
70
+ );
71
+ }
72
+ ```
73
+
74
+ `setState` updates state instantly (input feels native). `setUrl()` with no args flushes current state to the URL when the user finishes typing.
75
+
76
+ ### Discrete controls — `setUrl` directly
77
+
78
+ For click/select/toggle controls that fire at most a few times per second, skip the split. `setUrl` is already throttled.
79
+
80
+ ```typescript
81
+ <button onClick={() => setUrl({ sort: 'date' })}>Sort by date</button>
82
+ ```
83
+
84
+ ## Common Mistakes
85
+
86
+ ### CRITICAL `setUrl` inside `useEffect` → infinite update loop
87
+
88
+ (Cross-skill failure — also in `feature-state-hook`.)
89
+
90
+ Wrong:
91
+
92
+ ```typescript
93
+ React.useEffect(() => {
94
+ setUrl({ q: urlState.q.trim() }); // re-fires every URL change
95
+ }, [urlState, setUrl]);
96
+ ```
97
+
98
+ Correct:
99
+
100
+ ```typescript
101
+ React.useEffect(() => {
102
+ const trimmed = urlState.q.trim();
103
+ if (urlState.q !== trimmed) setUrl({ q: trimmed });
104
+ }, [urlState.q, setUrl]);
105
+ ```
106
+
107
+ URL throttling does not break a state→effect→setUrl→state cycle.
108
+
109
+ Source: Maintainer interview
110
+
111
+ ### HIGH Binding `setUrl` directly to onChange of a typing input
112
+
113
+ Wrong:
114
+
115
+ ```typescript
116
+ <input
117
+ value={urlState.q}
118
+ onChange={(e) => setUrl({ q: e.target.value })}
119
+ />
120
+ ```
121
+
122
+ Correct:
123
+
124
+ ```typescript
125
+ <input
126
+ value={urlState.q}
127
+ onChange={(e) => setState({ q: e.target.value })}
128
+ onBlur={() => setUrl()}
129
+ />
130
+ ```
131
+
132
+ Every keystroke fires a URL update. The library coalesces them with an internal timer, but the perceived behavior is laggy URL with extra rerenders (one for state, another when URL settles). Issue #78 was filed exactly for this symptom — the throttling is intentional.
133
+
134
+ Source: GitHub issue #78 (asmyshlyaev177/state-in-url); README "Update state only and sync to URL manually"
135
+
136
+ ### MEDIUM Expecting `window.location.search` to reflect `setUrl` synchronously
137
+
138
+ Wrong:
139
+
140
+ ```typescript
141
+ setUrl({ tab: 'b' });
142
+ console.log(window.location.search); // still the old value
143
+ ```
144
+
145
+ Correct:
146
+
147
+ ```typescript
148
+ setUrl({ tab: 'b' });
149
+ // `urlState.tab` already equals 'b' synchronously.
150
+ // `window.location.search` catches up after the throttle tick.
151
+ ```
152
+
153
+ `setUrl` is "last-write-wins" — it coalesces a burst of updates into one URL write on the next macrotask. Read `urlState`, not `window.location`.
154
+
155
+ Source: useUrlStateBase.ts (global `timer`); GitHub issue #78
156
+
157
+ ## See also
158
+
159
+ - `state-in-url/feature-state-hook` — base pattern this skill builds on.
160
+ - `state-in-url/nextjs-ssr` — for SSR-safe wiring of the search input on Next.js App Router.
@@ -0,0 +1,251 @@
1
+ ---
2
+ name: nextjs-ssr
3
+ description: >
4
+ SSR-safe useUrlState in Next.js App Router. Forward searchParams from server
5
+ pages (awaiting the Promise in Next.js 15+), call useSearchParams() in pure
6
+ client components, decide between useHistory true/false, and use a Proxy
7
+ (formerly middleware) to expose query params to server layouts. App Router
8
+ only — Pages Router is not supported. Load this skill for any use of
9
+ state-in-url/next or anytime URL state must be correct on first paint.
10
+ type: framework
11
+ library: state-in-url
12
+ framework: react
13
+ library_version: '6.1.3'
14
+ requires:
15
+ - feature-state-hook
16
+ sources:
17
+ - 'asmyshlyaev177/state-in-url:packages/urlstate/next/useUrlState/useUrlState.ts'
18
+ - 'asmyshlyaev177/state-in-url:packages/example-nextjs16/src/middleware.ts'
19
+ - 'asmyshlyaev177/state-in-url:README.md#with-server-side-rendering'
20
+ ---
21
+
22
+ This skill builds on `state-in-url/feature-state-hook`. Read it first for the module-scoped default-state rule.
23
+
24
+ # state-in-url — Next.js App Router SSR
25
+
26
+ Without `searchParams`, the first render of a `useUrlState` component has no URL knowledge. It renders defaults, then a client `useEffect` re-syncs from the URL → visible flash and a React hydration warning. The fix is to feed the URL into the hook on the server (via `searchParams` prop or a Proxy header) so the very first render is correct.
27
+
28
+ App Router only. Pages Router uses `next/router`, which `state-in-url/next` does not support — there are no plans to add it.
29
+
30
+ ## Setup
31
+
32
+ ### Server page forwarding `searchParams` (recommended)
33
+
34
+ ```typescript
35
+ // app/jobs/page.tsx (Server Component)
36
+ import { JobsList } from './JobsList';
37
+
38
+ export default async function Page({
39
+ searchParams,
40
+ }: {
41
+ searchParams: Promise<Record<string, string | string[] | undefined>>;
42
+ }) {
43
+ const sp = await searchParams; // Next.js 15+: searchParams is a Promise
44
+ return <JobsList searchParams={sp} />;
45
+ }
46
+ ```
47
+
48
+ ```typescript
49
+ // app/jobs/JobsList.tsx
50
+ 'use client';
51
+ import { useUrlState } from 'state-in-url/next';
52
+ import { JOBS_STATE } from 'features/jobs/jobsState';
53
+
54
+ export function JobsList({ searchParams }: { searchParams: object }) {
55
+ const { urlState, setUrl } = useUrlState(JOBS_STATE, { searchParams });
56
+ return <pre>{JSON.stringify(urlState)}</pre>;
57
+ }
58
+ ```
59
+
60
+ ### Pure client component using `useSearchParams`
61
+
62
+ When you can't (or don't want to) thread `searchParams` from a server parent:
63
+
64
+ ```typescript
65
+ 'use client';
66
+ import { useSearchParams } from 'next/navigation';
67
+ import { useUrlState } from 'state-in-url/next';
68
+ import { JOBS_STATE } from 'features/jobs/jobsState';
69
+
70
+ export function useJobsState() {
71
+ const searchParams = useSearchParams();
72
+ return useUrlState(JOBS_STATE, { searchParams });
73
+ }
74
+ ```
75
+
76
+ ## Core Patterns
77
+
78
+ ### `useHistory` — choosing the URL update mode
79
+
80
+ | Setting | Mechanism | Effect | Use when |
81
+ |---|---|---|---|
82
+ | `useHistory: true` (default) | `window.history.pushState` / `replaceState` | No `_rsc` round-trip per URL change | URL state is client-only UI (filters, drawers, tabs) |
83
+ | `useHistory: false` | `next/navigation`'s `router.push` / `router.replace` | RSC refetches on every URL change | URL state must refetch server data (e.g. `searchParams` drives a DB query in the page) |
84
+
85
+ ```typescript
86
+ useUrlState(FORM_STATE, { searchParams, useHistory: false });
87
+ ```
88
+
89
+ Default is `true`. Flip to `false` only when the server page needs to re-render with the new query.
90
+
91
+ ### Decoding state on the server for data fetching
92
+
93
+ ```typescript
94
+ // app/jobs/page.tsx
95
+ import { decodeState } from 'state-in-url/encodeState';
96
+ import { JOBS_STATE } from 'features/jobs/jobsState';
97
+ import { fetchJobs } from 'lib/jobs';
98
+
99
+ export default async function Page({ searchParams }) {
100
+ const sp = await searchParams;
101
+ const state = decodeState(new URLSearchParams(sp as Record<string, string>), JOBS_STATE);
102
+ const jobs = await fetchJobs({ status: state.status });
103
+ return <JobsList searchParams={sp} jobs={jobs} />;
104
+ }
105
+ ```
106
+
107
+ ### Reading URL state in a server layout (Proxy workaround)
108
+
109
+ Server layouts don't receive `searchParams`. Set up a Proxy (Next.js 16+ — `middleware.ts` still works as a deprecated alias) to surface the query string as a request header, then decode in the layout. With this setup the layout renders correctly on first paint with no extra rerenders.
110
+
111
+ ```typescript
112
+ // proxy.ts (or middleware.ts, same content)
113
+ import type { NextRequest } from 'next/server';
114
+ import { NextResponse } from 'next/server';
115
+
116
+ export function proxy(request: NextRequest) {
117
+ const sp = (request.url.includes('_next') ? '' : request.url).split('?')[1] ?? '';
118
+ const headers = new Headers(request.headers);
119
+ headers.set('searchParams', sp);
120
+ return NextResponse.next({ request: { headers } });
121
+ }
122
+ ```
123
+
124
+ ```typescript
125
+ // app/jobs/layout.tsx
126
+ import { headers } from 'next/headers';
127
+ import { decodeState } from 'state-in-url/encodeState';
128
+ import { JOBS_STATE } from 'features/jobs/jobsState';
129
+
130
+ export default async function Layout({ children }: { children: React.ReactNode }) {
131
+ const sp = (await headers()).get('searchParams') ?? '';
132
+ const initial = decodeState(sp, JOBS_STATE);
133
+
134
+ return (
135
+ <>
136
+ <LayoutHeader initial={initial} />
137
+ {children}
138
+ </>
139
+ );
140
+ }
141
+ ```
142
+
143
+ ## Common Mistakes
144
+
145
+ ### CRITICAL Not passing `searchParams` in Next.js App Router
146
+
147
+ Wrong:
148
+
149
+ ```typescript
150
+ // page.tsx (server)
151
+ export default function Page() { return <Form />; }
152
+
153
+ // Form.tsx (client)
154
+ const { urlState } = useUrlState(FORM_STATE); // missing searchParams
155
+ ```
156
+
157
+ Correct:
158
+
159
+ ```typescript
160
+ // page.tsx
161
+ export default async function Page({ searchParams }) {
162
+ const sp = await searchParams;
163
+ return <Form searchParams={sp} />;
164
+ }
165
+
166
+ // Form.tsx
167
+ const { urlState } = useUrlState(FORM_STATE, { searchParams });
168
+ ```
169
+
170
+ `searchParams` makes the **initial** state correct. Without it, the first render uses defaults, then a client `useEffect` re-syncs from the URL on the next tick — causing a visible flash and a hydration warning. URL state still survives refresh either way (URL is the source of truth), but the initial paint is wrong. With a Proxy feeding the layout, no extra rerender happens at all. Most common Next.js issue (#40, #60).
171
+
172
+ Source: GitHub issues #40, #60 (asmyshlyaev177/state-in-url); maintainer interview
173
+
174
+ ### HIGH Forgetting to await `searchParams` in Next.js 15+
175
+
176
+ Wrong:
177
+
178
+ ```typescript
179
+ export default function Page({ searchParams }) {
180
+ return <Form searchParams={searchParams} />; // Promise, not the value
181
+ }
182
+ ```
183
+
184
+ Correct:
185
+
186
+ ```typescript
187
+ export default async function Page({ searchParams }) {
188
+ const sp = await searchParams;
189
+ return <Form searchParams={sp} />;
190
+ }
191
+ ```
192
+
193
+ Next.js 15 changed page-level `searchParams` to a Promise. The library silently decodes nothing from an unresolved Promise.
194
+
195
+ Source: Next.js 15 migration notes
196
+
197
+ ### HIGH Using `state-in-url/next` in a Pages Router project
198
+
199
+ Wrong:
200
+
201
+ ```typescript
202
+ // pages/index.tsx
203
+ import { useUrlState } from 'state-in-url/next'; // imports next/navigation
204
+ ```
205
+
206
+ Correct:
207
+
208
+ Use App Router (`app/` directory). The library does not support Pages Router and there are no plans to add support. If you must stay on Pages Router, build a custom hook with `useUrlStateBase` and a `next/router`-backed router object.
209
+
210
+ Source: README "Gotchas" #3
211
+
212
+ ### MEDIUM Setting `useHistory: false` unnecessarily
213
+
214
+ Wrong:
215
+
216
+ ```typescript
217
+ useUrlState(FORM_STATE, { searchParams, useHistory: false });
218
+ // every keystroke triggers a Next.js _rsc payload fetch
219
+ ```
220
+
221
+ Correct:
222
+
223
+ ```typescript
224
+ useUrlState(FORM_STATE, { searchParams });
225
+ // useHistory defaults to true → window.history, no server round-trip
226
+ ```
227
+
228
+ `useHistory: false` is only correct when the server page must re-fetch on URL changes. For UI-only state (filters, drawers, tabs) it triples request traffic.
229
+
230
+ Source: JSDoc on `useUrlState` params; vercel/next.js#59167
231
+
232
+ ### MEDIUM Trying to read `searchParams` in a server layout directly
233
+
234
+ Wrong:
235
+
236
+ ```typescript
237
+ export default function Layout({ children, searchParams }: any) {
238
+ // searchParams is always undefined in layouts
239
+ }
240
+ ```
241
+
242
+ Correct:
243
+
244
+ Use the Proxy + header pattern shown above (`proxy.ts` setting `searchParams` header → layout reads via `headers()` and `decodeState`). With this in place there is no initial lag or extra rerender.
245
+
246
+ Source: README "Using hook in layout component"; packages/example-nextjs16/src/middleware.ts; maintainer interview
247
+
248
+ ## See also
249
+
250
+ - `state-in-url/feature-state-hook` — base pattern; required reading.
251
+ - `state-in-url/input-handling` — for text-input UX with the Next.js hook.
@@ -0,0 +1,180 @@
1
+ ---
2
+ name: react-router-remix-setup
3
+ description: >
4
+ Set up useUrlState for React Router v7 (state-in-url/react-router), React
5
+ Router v6 (state-in-url/react-router6 — moved here in 6.0.0), and Remix v2
6
+ (state-in-url/remix). The hook API is identical across all three; only the
7
+ import path and per-call NavigateOptions (preventScrollReset, state, replace)
8
+ differ. Load this skill when wiring useUrlState into any non-Next.js
9
+ React router or migrating from an older import path.
10
+ type: framework
11
+ library: state-in-url
12
+ framework: react
13
+ library_version: '6.1.3'
14
+ requires:
15
+ - feature-state-hook
16
+ sources:
17
+ - 'asmyshlyaev177/state-in-url:packages/urlstate/react-router/useUrlState/'
18
+ - 'asmyshlyaev177/state-in-url:packages/urlstate/react-router6/useUrlState/'
19
+ - 'asmyshlyaev177/state-in-url:packages/urlstate/remix/useUrlState/'
20
+ - 'asmyshlyaev177/state-in-url:CHANGELOG.md#600-2025-08-05'
21
+ ---
22
+
23
+ This skill builds on `state-in-url/feature-state-hook`. Read it first for the module-scoped default-state rule.
24
+
25
+ # state-in-url — React Router and Remix
26
+
27
+ The hook signature, return shape, and behavior of `useUrlState` are identical across React Router v6, v7, and Remix v2. Three things differ:
28
+
29
+ | Aspect | Difference |
30
+ |---|---|
31
+ | Import path | `state-in-url/react-router` (v7), `state-in-url/react-router6` (v6), `state-in-url/remix` (Remix v2) |
32
+ | `setUrl` per-call options | Accepts `NavigateOptions` from `react-router` (e.g. `preventScrollReset`, `state`, `replace`) |
33
+ | SSR | Remix v2 routes can hydrate from loader data; React Router CSR-only by default |
34
+
35
+ ## Setup
36
+
37
+ ### React Router v7
38
+
39
+ ```typescript
40
+ import { useUrlState } from 'state-in-url/react-router';
41
+
42
+ type FiltersState = { sort: 'name' | 'date'; page: number };
43
+ const FILTERS_STATE: FiltersState = { sort: 'name', page: 1 };
44
+
45
+ export function FiltersBar() {
46
+ const { urlState, setUrl } = useUrlState(FILTERS_STATE);
47
+ return <button onClick={() => setUrl({ page: urlState.page + 1 })}>Next</button>;
48
+ }
49
+ ```
50
+
51
+ ### React Router v6 (since state-in-url 6.0.0)
52
+
53
+ ```typescript
54
+ // Note the /react-router6 subpath
55
+ import { useUrlState } from 'state-in-url/react-router6';
56
+
57
+ type FiltersState = { sort: 'name' | 'date'; page: number };
58
+ const FILTERS_STATE: FiltersState = { sort: 'name', page: 1 };
59
+
60
+ export function FiltersBar() {
61
+ const { urlState, setUrl } = useUrlState(FILTERS_STATE);
62
+ return <button onClick={() => setUrl({ page: urlState.page + 1 })}>Next</button>;
63
+ }
64
+ ```
65
+
66
+ ### Remix v2
67
+
68
+ ```typescript
69
+ import { useUrlState } from 'state-in-url/remix';
70
+
71
+ type FiltersState = { sort: 'name' | 'date'; page: number };
72
+ const FILTERS_STATE: FiltersState = { sort: 'name', page: 1 };
73
+
74
+ export default function Route() {
75
+ const { urlState, setUrl } = useUrlState(FILTERS_STATE);
76
+ return <button onClick={() => setUrl({ page: urlState.page + 1 })}>Next</button>;
77
+ }
78
+ ```
79
+
80
+ ## Core Patterns
81
+
82
+ ### Pass NavigateOptions per call
83
+
84
+ ```typescript
85
+ setUrl({ tab: 'b' }, { replace: false, preventScrollReset: true });
86
+ ```
87
+
88
+ `replace: false` creates a new history entry (push) instead of replacing.
89
+ `preventScrollReset: true` keeps the page scroll position after the navigation — important when URL state controls in-page UI rather than route content.
90
+
91
+ ### `basename` support
92
+
93
+ Both `useUrlState` hooks for React Router respect the router's `basename`. No extra configuration is needed in the hook.
94
+
95
+ ```typescript
96
+ // router setup
97
+ <BrowserRouter basename="/admin">
98
+ <App />
99
+ </BrowserRouter>
100
+ // URL state updates happen at /admin?... not /...
101
+ ```
102
+
103
+ ### Functional update preserving other fields
104
+
105
+ ```typescript
106
+ setUrl((curr) => ({ ...curr, page: curr.page + 1 }));
107
+ ```
108
+
109
+ ## Common Mistakes
110
+
111
+ ### CRITICAL Importing `/react-router` for a React Router v6 project
112
+
113
+ Wrong:
114
+
115
+ ```typescript
116
+ // react-router v6 project
117
+ import { useUrlState } from 'state-in-url/react-router'; // targets v7 since 6.0.0
118
+ ```
119
+
120
+ Correct:
121
+
122
+ ```typescript
123
+ import { useUrlState } from 'state-in-url/react-router6';
124
+ ```
125
+
126
+ As of state-in-url v6.0.0 (Aug 2025) the v6 implementation moved to its own subpath. `/react-router` now targets React Router v7 only. Agents trained on older code will use the wrong import.
127
+
128
+ Source: CHANGELOG v6.0.0 BREAKING CHANGES (asmyshlyaev177/state-in-url)
129
+
130
+ ### HIGH Using the deprecated `useUrlState({ allParamsObj })` signature
131
+
132
+ Wrong:
133
+
134
+ ```typescript
135
+ useUrlState({ defaultState: FORM_STATE, replace: true }); // removed in v5.0.0
136
+ ```
137
+
138
+ Correct:
139
+
140
+ ```typescript
141
+ useUrlState(FORM_STATE, { replace: true });
142
+ ```
143
+
144
+ state-in-url v5.0.0 removed the old object-wrapped call shape. Defaults are now passed positionally; options go in the second argument.
145
+
146
+ Source: CHANGELOG v5.0.0 BREAKING CHANGES (asmyshlyaev177/state-in-url)
147
+
148
+ ### CRITICAL `defaultState` defined inside the React component
149
+
150
+ (Cross-skill failure — also in `feature-state-hook`.)
151
+
152
+ Wrong:
153
+
154
+ ```typescript
155
+ export function Filters() {
156
+ const defaults = { sort: 'name' };
157
+ const { urlState } = useUrlState(defaults);
158
+ }
159
+ ```
160
+
161
+ Correct:
162
+
163
+ ```typescript
164
+ type FiltersState = { sort: 'name' | 'date' };
165
+ const FILTERS_STATE: FiltersState = { sort: 'name' };
166
+
167
+ export function Filters() {
168
+ const { urlState } = useUrlState(FILTERS_STATE);
169
+ }
170
+ ```
171
+
172
+ Same identity rule as Next.js. A fresh default object each render breaks sharing and initial-state seeding.
173
+
174
+ Source: GitHub issues #57, #60, #69 (asmyshlyaev177/state-in-url)
175
+
176
+ ## See also
177
+
178
+ - `state-in-url/feature-state-hook` — base pattern.
179
+ - `state-in-url/input-handling` — for text-input UX inside a router-driven page.
180
+ - `state-in-url/form-library-integration` — for pairing with `react-hook-form`.
@@ -0,0 +1,158 @@
1
+ ---
2
+ name: shared-state-no-url
3
+ description: >
4
+ Use useSharedState (from state-in-url, NOT a framework subpath) as a
5
+ framework-agnostic cross-component state primitive when URL sync is
6
+ explicitly NOT wanted. Same module-scoped default-state-identity rule
7
+ as useUrlState. A lightweight Context.Provider replacement that works
8
+ in React, Next.js, Vite, Remix without setup. Load this skill when the
9
+ user wants shared state but specifies "no URL", or for state that is
10
+ sensitive, ephemeral, or too large to live in the URL.
11
+ type: core
12
+ library: state-in-url
13
+ library_version: '6.1.3'
14
+ sources:
15
+ - 'asmyshlyaev177/state-in-url:packages/urlstate/useSharedState/'
16
+ - 'asmyshlyaev177/state-in-url:README.md#usesharedstate-hook-for-reactjs'
17
+ ---
18
+
19
+ # state-in-url — Shared state without URL sync
20
+
21
+ `useSharedState` is `state-in-url`'s lightweight cross-component state primitive — no Context provider, no Redux, no Zustand store. State is shared between any components that pass the **same module-scoped default-state object** (identity, not deep equality). It is framework-agnostic and has no URL involvement.
22
+
23
+ **Decision:** use `useUrlState` (see `state-in-url/feature-state-hook`) by default. Reach for `useSharedState` only when the user explicitly opts out of URL sync, or when the state is sensitive / ephemeral / too large for a URL.
24
+
25
+ ## Setup
26
+
27
+ ```typescript
28
+ // features/cart/cartState.ts
29
+ export type CartState = {
30
+ items: { id: string; qty: number }[];
31
+ isOpen: boolean;
32
+ };
33
+
34
+ export const CART_STATE: CartState = {
35
+ items: [],
36
+ isOpen: false,
37
+ };
38
+ ```
39
+
40
+ ```typescript
41
+ // any component
42
+ 'use client';
43
+ import { useSharedState } from 'state-in-url'; // NB: top-level subpath
44
+
45
+ import { CART_STATE } from 'features/cart/cartState';
46
+
47
+ export function CartButton() {
48
+ const { state, setState } = useSharedState(CART_STATE);
49
+
50
+ return (
51
+ <button onClick={() => setState((curr) => ({ ...curr, isOpen: !curr.isOpen }))}>
52
+ Cart ({state.items.length})
53
+ </button>
54
+ );
55
+ }
56
+ ```
57
+
58
+ Any other component calling `useSharedState(CART_STATE)` reads and writes the same store.
59
+
60
+ ## Core Patterns
61
+
62
+ ### Functional update
63
+
64
+ ```typescript
65
+ setState((curr) => ({ ...curr, items: curr.items.concat(newItem) }));
66
+ ```
67
+
68
+ ### Read-only `getState` (avoid rerender)
69
+
70
+ ```typescript
71
+ const { getState } = useSharedState(CART_STATE);
72
+
73
+ const onSubmit = () => {
74
+ const { items } = getState(); // doesn't subscribe to changes
75
+ api.checkout(items);
76
+ };
77
+ ```
78
+
79
+ ### SSR initial state
80
+
81
+ ```typescript
82
+ const { state } = useSharedState(CART_STATE, () => readFromCookie());
83
+ ```
84
+
85
+ The second-argument function runs once for the initial value (useful for cookie-driven SSR hydration).
86
+
87
+ ## Common Mistakes
88
+
89
+ ### MEDIUM Reaching for `useSharedState` when URL sync is wanted
90
+
91
+ Wrong:
92
+
93
+ ```typescript
94
+ // Filters that the user wants to share via copy/paste link
95
+ const { state, setState } = useSharedState(FILTERS_STATE);
96
+ // Page refresh loses everything. Link sharing impossible.
97
+ ```
98
+
99
+ Correct:
100
+
101
+ ```typescript
102
+ import { useUrlState } from 'state-in-url/next';
103
+ const { urlState, setUrl } = useUrlState(FILTERS_STATE, { searchParams });
104
+ ```
105
+
106
+ `useSharedState` has no URL involvement. Pick `useUrlState` whenever state should survive a refresh or be shareable; pick `useSharedState` only when the user explicitly says "no URL".
107
+
108
+ Source: Maintainer interview
109
+
110
+ ### HIGH Default state defined inside the React component
111
+
112
+ Wrong:
113
+
114
+ ```typescript
115
+ function CartButton() {
116
+ const initial = { items: [], isOpen: false };
117
+ useSharedState(initial); // different identity in every component → no sharing
118
+ }
119
+ ```
120
+
121
+ Correct:
122
+
123
+ ```typescript
124
+ // cartState.ts (module scope)
125
+ export type CartState = { items: { id: string; qty: number }[]; isOpen: boolean };
126
+ export const CART_STATE: CartState = { items: [], isOpen: false };
127
+
128
+ function CartButton() {
129
+ useSharedState(CART_STATE);
130
+ }
131
+ ```
132
+
133
+ Same identity-based sharing rule as `useUrlState`. A non-static default breaks cross-component sharing silently — each call creates an independent store.
134
+
135
+ Source: Inferred from `useUrlState` pattern (shared internals)
136
+
137
+ ### MEDIUM Storing actual secrets in shared state
138
+
139
+ Wrong:
140
+
141
+ ```typescript
142
+ const SESSION_STATE = { token: '', userId: '' };
143
+ useSharedState(SESSION_STATE);
144
+ ```
145
+
146
+ Correct:
147
+
148
+ Use a proper auth store (cookies, secure storage, framework auth helper). `useSharedState` is in-memory and reasonable for app state, but it's not a vault — any component on the page can read it via the shared identity.
149
+
150
+ ## When NOT to use this skill
151
+
152
+ - The data should be shareable by URL → `state-in-url/feature-state-hook` instead.
153
+ - The data is server cache (lists, paginated queries) → `@tanstack/react-query` or `swr`.
154
+ - The data is persisted user prefs across sessions → `localStorage` or a settings API.
155
+
156
+ ## See also
157
+
158
+ - `state-in-url/feature-state-hook` — when URL sync IS wanted (the default choice).