zustand-querystring 0.4.1 → 0.6.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
@@ -1,84 +1,304 @@
1
1
  # zustand-querystring
2
2
 
3
- A Zustand middleware that syncs the store with the querystring.
3
+ Zustand middleware for URL query string sync.
4
4
 
5
- Try on [StackBlitz](https://stackblitz.com/github/nitedani/zustand-querystring/tree/main/examples/react) (You need to click "Open in New Tab")
5
+ ```bash
6
+ npm install zustand-querystring
7
+ ```
8
+
9
+ ## Usage
10
+
11
+ ```ts
12
+ import { create } from 'zustand';
13
+ import { querystring } from 'zustand-querystring';
6
14
 
7
- Examples:
15
+ const useStore = create(
16
+ querystring(
17
+ (set) => ({
18
+ search: '',
19
+ page: 1,
20
+ setSearch: (search) => set({ search }),
21
+ setPage: (page) => set({ page }),
22
+ }),
23
+ {
24
+ select: () => ({ search: true, page: true }),
25
+ }
26
+ )
27
+ );
28
+ // URL: ?search=hello&page=2
29
+ ```
8
30
 
9
- - [React](./examples/react/)
10
- - [NextJS](./examples/next/)
31
+ ---
11
32
 
12
- Quickstart:
33
+ ## Options
13
34
 
14
35
  ```ts
15
- import create from 'zustand';
16
- import { querystring } from 'zustand-querystring';
36
+ querystring(storeCreator, {
37
+ select: undefined, // which fields to sync
38
+ key: false, // false | 'state'
39
+ prefix: '', // prefix for URL params
40
+ format: marked, // serialization format
41
+ syncNull: false, // sync null values
42
+ syncUndefined: false, // sync undefined values
43
+ url: undefined, // request URL for SSR
44
+ })
45
+ ```
17
46
 
18
- interface Store {
19
- count: number;
20
- ticks: number;
21
- someNestedState: {
22
- nestedCount: number;
23
- hello: string;
24
- };
25
- }
47
+ ### `select`
48
+
49
+ Controls which state fields sync to URL. Receives pathname, returns object with `true` for fields to sync.
50
+
51
+ ```ts
52
+ // All fields
53
+ select: () => ({ search: true, page: true, filters: true })
54
+
55
+ // Route-based
56
+ select: (pathname) => ({
57
+ search: true,
58
+ filters: pathname.startsWith('/products'),
59
+ adminSettings: pathname.startsWith('/admin'),
60
+ })
61
+
62
+ // Nested fields
63
+ select: () => ({
64
+ user: {
65
+ name: true,
66
+ settings: { theme: true },
67
+ },
68
+ })
69
+ ```
70
+
71
+ ### `key`
72
+
73
+ - `false` (default): Each field becomes a separate URL param
74
+ ```
75
+ ?search=hello&page=2&filters.sort=name
76
+ ```
77
+ - `'state'` (or any string): All state in one param
78
+ ```
79
+ ?state=search%3Dhello%2Cpage%3A2
80
+ ```
81
+
82
+ ### `prefix`
83
+
84
+ Adds prefix to all params. Use when multiple stores share URL.
26
85
 
27
- export const useStore = create<Store>()(
86
+ ```ts
87
+ querystring(storeA, { prefix: 'a_', select: () => ({ search: true }) })
88
+ querystring(storeB, { prefix: 'b_', select: () => ({ filter: true }) })
89
+ // URL: ?a_search=hello&b_filter=active
90
+ ```
91
+
92
+ ### `syncNull` / `syncUndefined`
93
+
94
+ By default, `null` and `undefined` reset to initial state (removed from URL). Set to `true` to write them.
95
+
96
+ ### `url`
97
+
98
+ For SSR, pass the request URL:
99
+
100
+ ```ts
101
+ querystring(store, { url: request.url, select: () => ({ search: true }) })
102
+ ```
103
+
104
+ ---
105
+
106
+ ## How State Syncs
107
+
108
+ 1. **On page load**: URL → State
109
+ 2. **On state change**: State → URL (via `replaceState`)
110
+
111
+ Only values **different from initial state** are written to URL:
112
+
113
+ ```ts
114
+ // Initial: { search: '', page: 1, sort: 'date' }
115
+ // Current: { search: 'hello', page: 1, sort: 'name' }
116
+ // URL: ?search=hello&sort=name
117
+ // (page omitted - matches initial)
118
+ ```
119
+
120
+ Type handling:
121
+ - Objects: recursively diffed
122
+ - Arrays, Dates: compared as whole values
123
+ - Functions: never synced
124
+
125
+ ---
126
+
127
+ ## Formats
128
+
129
+ Three built-in formats:
130
+
131
+ | Format | Example Output |
132
+ |--------|----------------|
133
+ | `marked` | `count:5,tags@a,b~` |
134
+ | `plain` | `count=5&tags=a,b` |
135
+ | `json` | `count=5&tags=%5B%22a%22%5D` |
136
+
137
+ ```ts
138
+ import { marked } from 'zustand-querystring/format/marked';
139
+ import { plain } from 'zustand-querystring/format/plain';
140
+ import { json } from 'zustand-querystring/format/json';
141
+
142
+ querystring(store, { format: plain })
143
+ ```
144
+
145
+ ### Marked Format (default)
146
+
147
+ Type markers: `:` primitive, `=` string, `@` array, `.` object
148
+
149
+ Delimiters: `,` separator, `~` terminator, `_` escape
150
+
151
+ ```ts
152
+ import { createFormat } from 'zustand-querystring/format/marked';
153
+
154
+ const format = createFormat({
155
+ typeObject: '.',
156
+ typeArray: '@',
157
+ typeString: '=',
158
+ typePrimitive: ':',
159
+ separator: ',',
160
+ terminator: '~',
161
+ escapeChar: '_',
162
+ datePrefix: 'D',
163
+ });
164
+ ```
165
+
166
+ ### Plain Format
167
+
168
+ Dot notation for nesting, comma-separated arrays.
169
+
170
+ ```ts
171
+ import { createFormat } from 'zustand-querystring/format/plain';
172
+
173
+ const format = createFormat({
174
+ entrySeparator: ',', // between entries in namespaced mode
175
+ nestingSeparator: '.', // for nested keys
176
+ arraySeparator: ',', // or 'repeat' for ?tags=a&tags=b&tags=c
177
+ escapeChar: '_',
178
+ nullString: 'null',
179
+ undefinedString: 'undefined',
180
+ infinityString: 'Infinity', // string representation of Infinity
181
+ negativeInfinityString: '-Infinity',
182
+ nanString: 'NaN',
183
+ });
184
+ ```
185
+
186
+ ### JSON Format
187
+
188
+ URL-encoded JSON. No configuration.
189
+
190
+ ---
191
+
192
+ ## Custom Format
193
+
194
+ Implement `QueryStringFormat`:
195
+
196
+ ```ts
197
+ import type { QueryStringFormat, QueryStringParams, ParseContext } from 'zustand-querystring';
198
+
199
+ const myFormat: QueryStringFormat = {
200
+ // For key: 'state' (namespaced mode)
201
+ stringify(state: object): string {
202
+ return encodeURIComponent(JSON.stringify(state));
203
+ },
204
+ parse(value: string, ctx?: ParseContext): object {
205
+ return JSON.parse(decodeURIComponent(value));
206
+ },
207
+
208
+ // For key: false (standalone mode)
209
+ stringifyStandalone(state: object): QueryStringParams {
210
+ const result: QueryStringParams = {};
211
+ for (const [key, value] of Object.entries(state)) {
212
+ result[key] = [encodeURIComponent(JSON.stringify(value))];
213
+ }
214
+ return result;
215
+ },
216
+ parseStandalone(params: QueryStringParams, ctx: ParseContext): object {
217
+ const result: Record<string, unknown> = {};
218
+ for (const [key, values] of Object.entries(params)) {
219
+ result[key] = JSON.parse(decodeURIComponent(values[0]));
220
+ }
221
+ return result;
222
+ },
223
+ };
224
+
225
+ querystring(store, { format: myFormat })
226
+ ```
227
+
228
+ Types:
229
+ - `QueryStringParams` = `Record<string, string[]>` (values always arrays)
230
+ - `ctx.initialState` available for type coercion
231
+
232
+ ---
233
+
234
+ ## Examples
235
+
236
+ ### Search with reset
237
+
238
+ ```ts
239
+ const useStore = create(
28
240
  querystring(
29
- (set, get) => ({
30
- count: 0,
31
- ticks: 0,
32
- someNestedState: {
33
- nestedCount: 0,
34
- hello: 'Hello',
35
- },
241
+ (set) => ({
242
+ query: '',
243
+ page: 1,
244
+ setQuery: (query) => set({ query, page: 1 }), // reset page on new query
245
+ setPage: (page) => set({ page }),
36
246
  }),
37
- {
38
- // select controls what part of the state is synced with the query string
39
- // pathname is the current route (e.g. /about or /)
40
- select(pathname) {
41
- return {
42
- count: true,
43
- // ticks: false, <- false by default
44
-
45
- someNestedState: {
46
- nestedCount: true,
47
- hello: '/about' === pathname,
48
- },
49
-
50
- // OR select the whole nested state
51
- // someNestedState: true
52
- };
53
- },
54
- },
55
- ),
247
+ { select: () => ({ query: true, page: true }) }
248
+ )
56
249
  );
57
250
  ```
58
251
 
59
- querystring options:
252
+ ### Multiple stores with prefixes
253
+
254
+ ```ts
255
+ const useFilters = create(
256
+ querystring(filtersStore, {
257
+ prefix: 'f_',
258
+ select: () => ({ category: true, price: true }),
259
+ })
260
+ );
60
261
 
61
- - <b>select</b> - the select option controls what part of the state is synced with the query string
62
- - <b>key: string | false</b> - the key option controls how the state is stored in the querystring (default: 'state'). Set to `false` for standalone mode where each state key becomes a separate query parameter.
63
- - <b>url</b> - the url option is used to provide the request url on the server side render
64
- - <b>format</b> - custom format for stringify/parse (default: JSON-based format). Use `readable` from `zustand-querystring/format/readable` for human-readable URLs.
65
- - <b>syncNull: boolean</b> - when true, null values that differ from initial state are synced to URL (default: false)
66
- - <b>syncUndefined: boolean</b> - when true, undefined values that differ from initial state are synced to URL (default: false)
262
+ const usePagination = create(
263
+ querystring(paginationStore, {
264
+ prefix: 'p_',
265
+ select: () => ({ page: true, limit: true }),
266
+ })
267
+ );
268
+ // URL: ?f_category=shoes&f_price=100&p_page=2&p_limit=20
269
+ ```
67
270
 
68
- ## Important Notes
271
+ ### Next.js SSR
69
272
 
70
- ### State Diffing
273
+ ```ts
274
+ // app/page.tsx
275
+ export default async function Page({ searchParams }) {
276
+ // Store reads from URL on init
277
+ }
278
+ ```
71
279
 
72
- Only values that differ from the initial state are synced to the URL.
280
+ ---
73
281
 
74
- ### Null and Undefined Handling
282
+ ## Exports
75
283
 
76
- By default (`syncNull: false`, `syncUndefined: false`), `null` and `undefined` values are **not** synced to the URL. This means setting a value to `null` or `undefined` effectively "clears" it back to the initial state on page refresh.
284
+ ```ts
285
+ // Middleware
286
+ import { querystring } from 'zustand-querystring';
77
287
 
78
- If you want to preserve `null` or `undefined` values in the URL (so they persist across refreshes), set `syncNull: true` or `syncUndefined: true` in options.
288
+ // Formats
289
+ import { marked, createFormat } from 'zustand-querystring/format/marked';
290
+ import { plain, createFormat } from 'zustand-querystring/format/plain';
291
+ import { json } from 'zustand-querystring/format/json';
292
+
293
+ // Types
294
+ import type {
295
+ QueryStringOptions,
296
+ QueryStringFormat,
297
+ QueryStringParams,
298
+ ParseContext,
299
+ } from 'zustand-querystring';
300
+ ```
79
301
 
80
- ### State Types
302
+ ---
81
303
 
82
- - **Plain objects** (created with `{}`) are recursively compared with initial state - only changed properties are synced
83
- - **Arrays, Dates, RegExp, Maps, Sets, and class instances** are compared as atomic values - if any part changes, the entire value is synced
84
- - **Functions** are never synced to the URL
304
+ [Playground](https://stackblitz.com/github/nitedani/zustand-querystring/tree/main/examples/react) · [GitHub](https://github.com/nitedani/zustand-querystring)