state-in-url 6.1.2 → 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 +11 -0
- package/dist/package.json +579 -0
- package/package.json +10 -7
- 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
|
@@ -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).
|