zustand-querystring 0.4.1 → 0.5.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,301 @@
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
+ });
181
+ ```
182
+
183
+ ### JSON Format
184
+
185
+ URL-encoded JSON. No configuration.
186
+
187
+ ---
188
+
189
+ ## Custom Format
190
+
191
+ Implement `QueryStringFormat`:
192
+
193
+ ```ts
194
+ import type { QueryStringFormat, QueryStringParams, ParseContext } from 'zustand-querystring';
195
+
196
+ const myFormat: QueryStringFormat = {
197
+ // For key: 'state' (namespaced mode)
198
+ stringify(state: object): string {
199
+ return encodeURIComponent(JSON.stringify(state));
200
+ },
201
+ parse(value: string, ctx?: ParseContext): object {
202
+ return JSON.parse(decodeURIComponent(value));
203
+ },
204
+
205
+ // For key: false (standalone mode)
206
+ stringifyStandalone(state: object): QueryStringParams {
207
+ const result: QueryStringParams = {};
208
+ for (const [key, value] of Object.entries(state)) {
209
+ result[key] = [encodeURIComponent(JSON.stringify(value))];
210
+ }
211
+ return result;
212
+ },
213
+ parseStandalone(params: QueryStringParams, ctx: ParseContext): object {
214
+ const result: Record<string, unknown> = {};
215
+ for (const [key, values] of Object.entries(params)) {
216
+ result[key] = JSON.parse(decodeURIComponent(values[0]));
217
+ }
218
+ return result;
219
+ },
220
+ };
221
+
222
+ querystring(store, { format: myFormat })
223
+ ```
224
+
225
+ Types:
226
+ - `QueryStringParams` = `Record<string, string[]>` (values always arrays)
227
+ - `ctx.initialState` available for type coercion
228
+
229
+ ---
230
+
231
+ ## Examples
232
+
233
+ ### Search with reset
234
+
235
+ ```ts
236
+ const useStore = create(
28
237
  querystring(
29
- (set, get) => ({
30
- count: 0,
31
- ticks: 0,
32
- someNestedState: {
33
- nestedCount: 0,
34
- hello: 'Hello',
35
- },
238
+ (set) => ({
239
+ query: '',
240
+ page: 1,
241
+ setQuery: (query) => set({ query, page: 1 }), // reset page on new query
242
+ setPage: (page) => set({ page }),
36
243
  }),
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
- ),
244
+ { select: () => ({ query: true, page: true }) }
245
+ )
56
246
  );
57
247
  ```
58
248
 
59
- querystring options:
249
+ ### Multiple stores with prefixes
250
+
251
+ ```ts
252
+ const useFilters = create(
253
+ querystring(filtersStore, {
254
+ prefix: 'f_',
255
+ select: () => ({ category: true, price: true }),
256
+ })
257
+ );
60
258
 
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)
259
+ const usePagination = create(
260
+ querystring(paginationStore, {
261
+ prefix: 'p_',
262
+ select: () => ({ page: true, limit: true }),
263
+ })
264
+ );
265
+ // URL: ?f_category=shoes&f_price=100&p_page=2&p_limit=20
266
+ ```
67
267
 
68
- ## Important Notes
268
+ ### Next.js SSR
69
269
 
70
- ### State Diffing
270
+ ```ts
271
+ // app/page.tsx
272
+ export default async function Page({ searchParams }) {
273
+ // Store reads from URL on init
274
+ }
275
+ ```
71
276
 
72
- Only values that differ from the initial state are synced to the URL.
277
+ ---
73
278
 
74
- ### Null and Undefined Handling
279
+ ## Exports
75
280
 
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.
281
+ ```ts
282
+ // Middleware
283
+ import { querystring } from 'zustand-querystring';
77
284
 
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.
285
+ // Formats
286
+ import { marked, createFormat } from 'zustand-querystring/format/marked';
287
+ import { plain, createFormat } from 'zustand-querystring/format/plain';
288
+ import { json } from 'zustand-querystring/format/json';
289
+
290
+ // Types
291
+ import type {
292
+ QueryStringOptions,
293
+ QueryStringFormat,
294
+ QueryStringParams,
295
+ ParseContext,
296
+ } from 'zustand-querystring';
297
+ ```
79
298
 
80
- ### State Types
299
+ ---
81
300
 
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
301
+ [Playground](https://stackblitz.com/github/nitedani/zustand-querystring/tree/main/examples/react) · [GitHub](https://github.com/nitedani/zustand-querystring)