paramsync 1.0.1 → 1.0.3
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 +72 -1286
- package/README.npm.md +153 -0
- package/package.json +15 -4
package/README.md
CHANGED
|
@@ -1,90 +1,27 @@
|
|
|
1
1
|
# paramsync
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
> Designed for live search inputs, multi-attribute facet filtering, price/metric range sliders, data table sorting, pagination, and modal filter drawers.
|
|
3
|
+
Type-safe, zero-overhead URL Search Parameters state manager for React and Next.js App Router applications.
|
|
5
4
|
|
|
6
|
-
[](https://www.npmjs.com/package/paramsync)
|
|
6
|
+
[](https://www.npmjs.com/package/paramsync)
|
|
7
|
+
[](https://bundlephobia.com/package/paramsync)
|
|
8
|
+
[](https://github.com/3b3zeem/paramsync)
|
|
10
9
|
|
|
11
10
|
---
|
|
12
11
|
|
|
13
|
-
##
|
|
12
|
+
## What is paramsync?
|
|
14
13
|
|
|
15
|
-
|
|
16
|
-
2. [Installation & Requirements](#2-installation--requirements)
|
|
17
|
-
3. [Quick Reference Cheatsheet](#3-quick-reference-cheatsheet)
|
|
18
|
-
4. [Quick Start Tutorial](#4-quick-start-tutorial)
|
|
19
|
-
5. [In-Depth Parsers Reference](#5-in-depth-parsers-reference)
|
|
20
|
-
- [Parser Core Interfaces](#parser-core-interfaces)
|
|
21
|
-
- [createParser Factory](#createparser-factory)
|
|
22
|
-
- [parseAsString](#parseasstring)
|
|
23
|
-
- [parseAsInteger](#parseasinteger)
|
|
24
|
-
- [parseAsFloat](#parseasfloat)
|
|
25
|
-
- [parseAsBoolean](#parseasboolean)
|
|
26
|
-
- [parseAsArray](#parseasarray)
|
|
27
|
-
- [parseAsNumberRange](#parseasnumberrange)
|
|
28
|
-
- [parseAsSort](#parseassort)
|
|
29
|
-
- [Clean URL Utility Helpers](#clean-url-utility-helpers)
|
|
30
|
-
6. [In-Depth Hooks Reference](#6-in-depth-hooks-reference)
|
|
31
|
-
- [useQueryState](#usequerystate)
|
|
32
|
-
- [Function Signature & Type Overloads](#function-signature--type-overloads)
|
|
33
|
-
- [Options Reference](#options-reference)
|
|
34
|
-
- [Return Values Reference](#return-values-reference)
|
|
35
|
-
- [Functional State Updates](#functional-state-updates)
|
|
36
|
-
- [Reset Mechanics](#reset-mechanics)
|
|
37
|
-
- [useBatchQueryParams](#usebatchqueryparams)
|
|
38
|
-
- [Function Signature](#usebatchqueryparams-signature)
|
|
39
|
-
- [BatchParamDescriptor](#batchparamdescriptor)
|
|
40
|
-
- [setBatchParams](#setbatchparams)
|
|
41
|
-
- [resetAllParams](#resetallparams)
|
|
42
|
-
7. [Clean URL Policy: Complete Specification](#7-clean-url-policy-complete-specification)
|
|
43
|
-
- [Decision Matrix Table](#decision-matrix-table)
|
|
44
|
-
- [Why Clean URLs Matter](#why-clean-urls-matter)
|
|
45
|
-
8. [Architecture & Data Fetching Patterns](#8-architecture--data-fetching-patterns)
|
|
46
|
-
- [Pattern 1: Server Components (RSC) + Direct API Fetching](#pattern-1-server-components-rsc--direct-api-fetching)
|
|
47
|
-
- [Pattern 2: Route Handlers & Server Actions](#pattern-2-route-handlers--server-actions)
|
|
48
|
-
- [Pattern 3: Client-Side with TanStack Query (React Query)](#pattern-3-client-side-with-tanstack-query-react-query)
|
|
49
|
-
- [Pattern 4: Client-Side with SWR](#pattern-4-client-side-with-swr)
|
|
50
|
-
- [Pattern 5: Native Fetch with useEffect](#pattern-5-native-fetch-with-useeffect)
|
|
51
|
-
9. [Dynamic Facet Filters from API](#9-dynamic-facet-filters-from-api)
|
|
52
|
-
- [Dynamic Facet Architecture](#dynamic-facet-architecture)
|
|
53
|
-
- [Complete Dynamic Facet Component](#complete-dynamic-facet-component)
|
|
54
|
-
10. [Pagination Coordination & Stale Page Prevention](#10-pagination-coordination--stale-page-prevention)
|
|
55
|
-
- [The Stale Page Problem Explained](#the-stale-page-problem-explained)
|
|
56
|
-
- [Auto-Reset Solution Patterns](#auto-reset-solution-patterns)
|
|
57
|
-
11. [Complete Production Recipes](#11-complete-production-recipes)
|
|
58
|
-
- [Recipe 1: Instant Reactive E-Commerce Filter Bar](#recipe-1-instant-reactive-e-commerce-filter-bar)
|
|
59
|
-
- [Recipe 2: Filter Drawer / Modal with Apply & Reset All](#recipe-2-filter-drawer--modal-with-apply--reset-all)
|
|
60
|
-
- [Recipe 3: Data Table with Sorting & Dynamic Page Size](#recipe-3-data-table-with-sorting--dynamic-page-size)
|
|
61
|
-
12. [TypeScript System & Type Narrowing](#12-typescript-system--type-narrowing)
|
|
62
|
-
13. [Next.js App Router Best Practices & Gotchas](#13-nextjs-app-router-best-practices--gotchas)
|
|
63
|
-
- [Suspense Boundary Requirement](#suspense-boundary-requirement)
|
|
64
|
-
- [Browser History vs In-Place Updates](#browser-history-vs-in-place-updates)
|
|
65
|
-
14. [Testing & Verification](#14-testing--verification)
|
|
66
|
-
15. [License](#15-license)
|
|
14
|
+
In Next.js App Router applications, managing state through URL search parameters using standard `useSearchParams()` and `router.replace()` often introduces keystroke latency during live search, UI stutter, and cluttered URLs filled with empty or default values.
|
|
67
15
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
`paramsync` is architected from the ground up to solve these challenges:
|
|
75
|
-
|
|
76
|
-
- **Zero Typing Lag (Optimistic Local State):** Controlled inputs update an internal React state immediately (`0ms`), ensuring smooth keystroke responsiveness while URL navigation is scheduled in the background.
|
|
77
|
-
- **Built-in Smart Debouncing:** Dedicated `debounceMs` natively delays Next.js router transitions while keeping controlled inputs responsive in real time.
|
|
78
|
-
- **Clean URL Policy:** Automatically purges query parameters when their values match defaults, are empty strings (`""`), or are empty arrays (`[]`).
|
|
79
|
-
- **Single & Batch Transitions:** Update a single parameter using `useQueryState` or update multiple filters simultaneously in a single transition using `useBatchQueryParams`.
|
|
80
|
-
- **Zero DOM Dependency in Parsers:** All parsers can run in both Node.js (Server Components, Route Handlers, Server Actions) and browser environments.
|
|
81
|
-
- **Strict TypeScript Inference:** Generic parsers automatically infer whether the returned state is nullable or non-nullable.
|
|
82
|
-
- **React 18 & 19 Native:** Uses `useTransition()` to mark URL navigation as a non-blocking transition and expose `isPending` for loading indicators.
|
|
83
|
-
- **Zero External Runtime Overhead:** Only peer-depends on `react` and `next`.
|
|
16
|
+
`paramsync` provides a seamless `[value, setValue]` state hook that binds React state directly to the URL:
|
|
17
|
+
- **0ms Input Latency:** Controlled inputs update local state instantaneously while background URL updates are debounced.
|
|
18
|
+
- **Clean URL Policy:** Default values, empty strings, and empty arrays are automatically purged from the query string.
|
|
19
|
+
- **Batch Transitions:** Multiple filter changes can be committed simultaneously in a single navigation transition via `useBatchQueryParams`.
|
|
20
|
+
- **Server Component Compatible:** Parsers have zero DOM dependencies and execute safely in React Server Components and Route Handlers.
|
|
84
21
|
|
|
85
22
|
---
|
|
86
23
|
|
|
87
|
-
##
|
|
24
|
+
## Installation
|
|
88
25
|
|
|
89
26
|
```bash
|
|
90
27
|
npm install paramsync
|
|
@@ -95,665 +32,26 @@ yarn add paramsync
|
|
|
95
32
|
```
|
|
96
33
|
|
|
97
34
|
### Peer Dependencies
|
|
98
|
-
- `react
|
|
99
|
-
- `next
|
|
100
|
-
|
|
101
|
-
### Module Compatibility
|
|
102
|
-
`paramsync` ships with dual ESM and CJS builds, along with full TypeScript type declarations (`.d.ts` and `.d.cts`) and sourcemaps.
|
|
103
|
-
|
|
104
|
-
---
|
|
105
|
-
|
|
106
|
-
## 3. Quick Reference Cheatsheet
|
|
107
|
-
|
|
108
|
-
### Parsers
|
|
109
|
-
|
|
110
|
-
| Parser | Parse Signature | Serialize Signature | Default Value Support | Clean URL Behavior |
|
|
111
|
-
| :--- | :--- | :--- | :--- | :--- |
|
|
112
|
-
| `parseAsString` | `string \| null` | `string` | `.withDefault(string)` | Removed if `""` or default |
|
|
113
|
-
| `parseAsInteger` | `number \| null` | `string` | `.withDefault(number)` | Removed if default |
|
|
114
|
-
| `parseAsFloat` | `number \| null` | `string` | `.withDefault(number)` | Removed if default |
|
|
115
|
-
| `parseAsBoolean` | `boolean \| null` | `string` | `.withDefault(boolean)` | Removed if default |
|
|
116
|
-
| `parseAsArray(itemParser, sep?)` | `T[]` | `string` | `.withDefault(T[])` | Removed if `[]` or default |
|
|
117
|
-
| `parseAsNumberRange(min, max, sep?)` | `NumberRange` | `string` | Built-in `{ min, max }` | Removed if matches bounds |
|
|
118
|
-
| `parseAsSort(field, order?, sep?)` | `SortOption<TField>` | `string` | Built-in `{ field, order }` | Removed if matches default |
|
|
119
|
-
| `createParser(config)` | `T` | `string` | `.withDefault(T)` | Removed if matches default |
|
|
120
|
-
|
|
121
|
-
### Hooks
|
|
122
|
-
|
|
123
|
-
| Hook | Signature | Primary Purpose |
|
|
124
|
-
| :--- | :--- | :--- |
|
|
125
|
-
| `useQueryState(key, parser, options?)` | `[value, setValue, reset, isPending]` | Reactive single-parameter state management with instant typing. |
|
|
126
|
-
| `useBatchQueryParams(options?)` | `{ setBatchParams, resetAllParams, isPending }` | Multi-parameter batch updates in a single URL transition (Drawer/Modal). |
|
|
35
|
+
- `react` >= 18.0.0
|
|
36
|
+
- `next` >= 13.0.0 (App Router)
|
|
127
37
|
|
|
128
38
|
---
|
|
129
39
|
|
|
130
|
-
##
|
|
131
|
-
|
|
132
|
-
Here is a minimal, fully functioning client component showing live search with debounce and pagination:
|
|
40
|
+
## Quick Example
|
|
133
41
|
|
|
134
42
|
```tsx
|
|
135
43
|
'use client';
|
|
136
44
|
|
|
137
|
-
import React from 'react';
|
|
138
45
|
import { useQueryState, parseAsString, parseAsInteger } from 'paramsync';
|
|
139
46
|
|
|
140
|
-
export default function
|
|
141
|
-
//
|
|
142
|
-
const [search, setSearch, resetSearch, isSearchPending] = useQueryState(
|
|
143
|
-
'q',
|
|
144
|
-
parseAsString.withDefault(''),
|
|
145
|
-
{ debounceMs: 300 }
|
|
146
|
-
);
|
|
147
|
-
|
|
148
|
-
// 2. Integer pagination parameter
|
|
149
|
-
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
|
|
150
|
-
|
|
151
|
-
return (
|
|
152
|
-
<div style={{ padding: '2rem', fontFamily: 'sans-serif' }}>
|
|
153
|
-
<h1>Catalog</h1>
|
|
154
|
-
|
|
155
|
-
{/* Live search input */}
|
|
156
|
-
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
|
|
157
|
-
<input
|
|
158
|
-
type="text"
|
|
159
|
-
value={search}
|
|
160
|
-
onChange={(e) => {
|
|
161
|
-
setPage(1); // Reset page on new search
|
|
162
|
-
setSearch(e.target.value);
|
|
163
|
-
}}
|
|
164
|
-
placeholder="Search items..."
|
|
165
|
-
/>
|
|
166
|
-
{search && <button onClick={resetSearch}>Clear</button>}
|
|
167
|
-
{isSearchPending && <span>Updating...</span>}
|
|
168
|
-
</div>
|
|
169
|
-
|
|
170
|
-
{/* Pagination controls */}
|
|
171
|
-
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
|
172
|
-
<button
|
|
173
|
-
disabled={page <= 1}
|
|
174
|
-
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
|
175
|
-
>
|
|
176
|
-
Previous
|
|
177
|
-
</button>
|
|
178
|
-
<span>Page {page}</span>
|
|
179
|
-
<button onClick={() => setPage((current) => current + 1)}>
|
|
180
|
-
Next
|
|
181
|
-
</button>
|
|
182
|
-
</div>
|
|
183
|
-
</div>
|
|
184
|
-
);
|
|
185
|
-
}
|
|
186
|
-
```
|
|
187
|
-
|
|
188
|
-
---
|
|
189
|
-
|
|
190
|
-
## 5. In-Depth Parsers Reference
|
|
191
|
-
|
|
192
|
-
### Parser Core Interfaces
|
|
193
|
-
|
|
194
|
-
All parsers conform to the `Parser<T>` interface:
|
|
195
|
-
|
|
196
|
-
```typescript
|
|
197
|
-
export interface Parser<T> {
|
|
198
|
-
parse: (value: string | null) => T;
|
|
199
|
-
serialize: (value: T) => string;
|
|
200
|
-
defaultValue?: T;
|
|
201
|
-
withDefault: (defaultValue: NonNullable<T>) => ParserWithDefault<NonNullable<T>>;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
export interface ParserWithDefault<T> extends Parser<T> {
|
|
205
|
-
readonly defaultValue: T;
|
|
206
|
-
}
|
|
207
|
-
```
|
|
208
|
-
|
|
209
|
-
When you chain `.withDefault(fallback)`, the return type narrows from `T | null` to `T`:
|
|
210
|
-
|
|
211
|
-
```typescript
|
|
212
|
-
// Type: Parser<string | null>
|
|
213
|
-
const nullableParser = parseAsString;
|
|
214
|
-
|
|
215
|
-
// Type: ParserWithDefault<string> (NonNullable)
|
|
216
|
-
const strictParser = parseAsString.withDefault('');
|
|
217
|
-
```
|
|
218
|
-
|
|
219
|
-
---
|
|
220
|
-
|
|
221
|
-
### `createParser` Factory
|
|
222
|
-
|
|
223
|
-
The `createParser` function allows you to define custom parsers for dates, JSON objects, coordinates, or domain-specific data structures:
|
|
224
|
-
|
|
225
|
-
```typescript
|
|
226
|
-
import { createParser } from 'paramsync';
|
|
227
|
-
|
|
228
|
-
export interface GeoPoint {
|
|
229
|
-
lat: number;
|
|
230
|
-
lng: number;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
export const parseAsGeoPoint = createParser<GeoPoint | null>({
|
|
234
|
-
parse: (raw) => {
|
|
235
|
-
if (!raw) return null;
|
|
236
|
-
const [latStr, lngStr] = raw.split(',');
|
|
237
|
-
const lat = Number(latStr);
|
|
238
|
-
const lng = Number(lngStr);
|
|
239
|
-
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
|
|
240
|
-
return { lat, lng };
|
|
241
|
-
},
|
|
242
|
-
serialize: (point) => {
|
|
243
|
-
if (!point) return '';
|
|
244
|
-
return `${point.lat},${point.lng}`;
|
|
245
|
-
},
|
|
246
|
-
});
|
|
247
|
-
```
|
|
248
|
-
|
|
249
|
-
Usage:
|
|
250
|
-
|
|
251
|
-
```tsx
|
|
252
|
-
const [coords, setCoords] = useQueryState('loc', parseAsGeoPoint);
|
|
253
|
-
```
|
|
254
|
-
|
|
255
|
-
---
|
|
256
|
-
|
|
257
|
-
### `parseAsString`
|
|
258
|
-
|
|
259
|
-
Parses raw query strings.
|
|
260
|
-
|
|
261
|
-
- **Parsing Behavior:** Returns the raw string value. Returns `null` if the parameter does not exist in the URL query string.
|
|
262
|
-
- **Serialization Behavior:** Converts strings to their text representation. Serializes `null` or `undefined` as `""`.
|
|
263
|
-
- **Clean URL Policy:** Deletes the key if the value is empty `""` or matches `defaultValue`.
|
|
264
|
-
|
|
265
|
-
```tsx
|
|
266
|
-
import { useQueryState, parseAsString } from 'paramsync';
|
|
267
|
-
|
|
268
|
-
// Returns: string | null
|
|
269
|
-
const [category, setCategory] = useQueryState('cat', parseAsString);
|
|
270
|
-
|
|
271
|
-
// Returns: string (always defined)
|
|
272
|
-
const [search, setSearch] = useQueryState('q', parseAsString.withDefault(''));
|
|
273
|
-
```
|
|
274
|
-
|
|
275
|
-
---
|
|
276
|
-
|
|
277
|
-
### `parseAsInteger`
|
|
278
|
-
|
|
279
|
-
Parses strictly formatted integer values.
|
|
280
|
-
|
|
281
|
-
- **Parsing Behavior:** Converts numeric strings to base-10 integers. Rejects decimals (`4.5`), text (`abc`), and mixed formats (`42px`), returning `null` or your default value.
|
|
282
|
-
- **Serialization Behavior:** Uses `Math.round(val).toString()`.
|
|
283
|
-
- **Clean URL Policy:** Deletes the key if the value matches `defaultValue`.
|
|
284
|
-
|
|
285
|
-
```tsx
|
|
286
|
-
import { useQueryState, parseAsInteger } from 'paramsync';
|
|
287
|
-
|
|
288
|
-
// Returns: number (defaults to 1)
|
|
289
|
-
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
|
|
290
|
-
|
|
291
|
-
// Incremental updates
|
|
292
|
-
setPage((prev) => prev + 1);
|
|
293
|
-
```
|
|
294
|
-
|
|
295
|
-
---
|
|
296
|
-
|
|
297
|
-
### `parseAsFloat`
|
|
298
|
-
|
|
299
|
-
Parses floating-point decimal numbers.
|
|
300
|
-
|
|
301
|
-
- **Parsing Behavior:** Validates that `Number(val)` is finite. Returns `null` on `NaN` or non-numeric strings.
|
|
302
|
-
- **Serialization Behavior:** Uses `String(val)`.
|
|
303
|
-
- **Clean URL Policy:** Deletes the key if the value matches `defaultValue`.
|
|
304
|
-
|
|
305
|
-
```tsx
|
|
306
|
-
import { useQueryState, parseAsFloat } from 'paramsync';
|
|
307
|
-
|
|
308
|
-
// Returns: number
|
|
309
|
-
const [minRating, setMinRating] = useQueryState('rating', parseAsFloat.withDefault(4.0));
|
|
310
|
-
```
|
|
311
|
-
|
|
312
|
-
---
|
|
313
|
-
|
|
314
|
-
### `parseAsBoolean`
|
|
315
|
-
|
|
316
|
-
Parses boolean query parameters.
|
|
317
|
-
|
|
318
|
-
- **Parsing Behavior:** Recognizes `'true'` and `'1'` as `true`. Recognizes `'false'` and `'0'` as `false`. Returns `null` for any other string.
|
|
319
|
-
- **Serialization Behavior:** Returns `'true'` or `'false'`.
|
|
320
|
-
- **Clean URL Policy:** Deletes the key if the value matches `defaultValue`.
|
|
321
|
-
|
|
322
|
-
```tsx
|
|
323
|
-
import { useQueryState, parseAsBoolean } from 'paramsync';
|
|
324
|
-
|
|
325
|
-
const [inStockOnly, setInStockOnly] = useQueryState(
|
|
326
|
-
'inStock',
|
|
327
|
-
parseAsBoolean.withDefault(false)
|
|
328
|
-
);
|
|
329
|
-
```
|
|
330
|
-
|
|
331
|
-
---
|
|
332
|
-
|
|
333
|
-
### `parseAsArray`
|
|
334
|
-
|
|
335
|
-
Parses delimited lists into typed JavaScript arrays.
|
|
336
|
-
|
|
337
|
-
- **Signature:** `parseAsArray<T>(itemParser: Parser<T>, separator = ','): ParserWithDefault<T[]>`
|
|
338
|
-
- **Parsing Behavior:** Splits raw query string by `separator`, parses each item with `itemParser.parse()`, and filters out any `null` elements. Returns `[]` on missing or empty parameters.
|
|
339
|
-
- **Serialization Behavior:** Maps items using `itemParser.serialize()` and joins them with `separator`. Returns `""` if array is empty.
|
|
340
|
-
- **Clean URL Policy:** Deletes the key if the array is empty `[]` or matches `defaultValue`.
|
|
341
|
-
|
|
342
|
-
```tsx
|
|
343
|
-
import { useQueryState, parseAsArray, parseAsString } from 'paramsync';
|
|
344
|
-
|
|
345
|
-
// URL: ?tags=shoes,watches,sale
|
|
346
|
-
const [tags, setTags] = useQueryState('tags', parseAsArray(parseAsString));
|
|
347
|
-
|
|
348
|
-
// Toggle item in array
|
|
349
|
-
const toggleTag = (tag: string) => {
|
|
350
|
-
setTags((prev) =>
|
|
351
|
-
prev.includes(tag) ? prev.filter((item) => item !== tag) : [...prev, tag]
|
|
352
|
-
);
|
|
353
|
-
};
|
|
354
|
-
```
|
|
355
|
-
|
|
356
|
-
Using custom separators:
|
|
357
|
-
|
|
358
|
-
```tsx
|
|
359
|
-
// URL: ?ids=10|20|30
|
|
360
|
-
const [ids, setIds] = useQueryState('ids', parseAsArray(parseAsInteger, '|'));
|
|
361
|
-
```
|
|
362
|
-
|
|
363
|
-
---
|
|
364
|
-
|
|
365
|
-
### `parseAsNumberRange`
|
|
366
|
-
|
|
367
|
-
Parses numeric range bounds for sliders and budget filters.
|
|
368
|
-
|
|
369
|
-
- **Signature:** `parseAsNumberRange(defaultMin: number, defaultMax: number, separator = '-'): ParserWithDefault<NumberRange>`
|
|
370
|
-
- **Type:**
|
|
371
|
-
```typescript
|
|
372
|
-
export interface NumberRange {
|
|
373
|
-
min: number;
|
|
374
|
-
max: number;
|
|
375
|
-
}
|
|
376
|
-
```
|
|
377
|
-
- **Parsing Behavior:** Splits value into two numbers. If parts cannot be parsed into finite numbers, falls back to `{ min: defaultMin, max: defaultMax }`.
|
|
378
|
-
- **Serialization Behavior:** Formats as `${min}${separator}${max}`.
|
|
379
|
-
- **Clean URL Policy:** Deletes the key if `min === defaultMin && max === defaultMax`.
|
|
380
|
-
|
|
381
|
-
```tsx
|
|
382
|
-
import { useQueryState, parseAsNumberRange } from 'paramsync';
|
|
383
|
-
|
|
384
|
-
// URL: ?price=100-500
|
|
385
|
-
const [price, setPrice] = useQueryState('price', parseAsNumberRange(0, 1000));
|
|
386
|
-
|
|
387
|
-
// Access bounds
|
|
388
|
-
console.log(price.min, price.max);
|
|
389
|
-
|
|
390
|
-
// Set new bounds
|
|
391
|
-
setPrice({ min: 150, max: 800 });
|
|
392
|
-
```
|
|
393
|
-
|
|
394
|
-
---
|
|
395
|
-
|
|
396
|
-
### `parseAsSort`
|
|
397
|
-
|
|
398
|
-
Provides type-safe table and collection sorting.
|
|
399
|
-
|
|
400
|
-
- **Signature:** `parseAsSort<TField extends string>(defaultField: TField, defaultOrder: 'asc' | 'desc' = 'asc', separator = '.'): ParserWithDefault<SortOption<TField>>`
|
|
401
|
-
- **Type:**
|
|
402
|
-
```typescript
|
|
403
|
-
export type SortOrder = 'asc' | 'desc';
|
|
404
|
-
export interface SortOption<TField extends string = string> {
|
|
405
|
-
field: TField;
|
|
406
|
-
order: SortOrder;
|
|
407
|
-
}
|
|
408
|
-
```
|
|
409
|
-
- **Parsing Behavior:** Splits by separator. Validates that the order segment is `'asc'` or `'desc'`.
|
|
410
|
-
- **Clean URL Policy:** Deletes the key if both field and order match the default values.
|
|
411
|
-
|
|
412
|
-
```tsx
|
|
413
|
-
import { useQueryState, parseAsSort } from 'paramsync';
|
|
414
|
-
|
|
415
|
-
type ProductField = 'price' | 'rating' | 'createdAt';
|
|
416
|
-
|
|
417
|
-
// URL: ?sort=price.desc
|
|
418
|
-
const [sort, setSort] = useQueryState(
|
|
419
|
-
'sort',
|
|
420
|
-
parseAsSort<ProductField>('createdAt', 'desc')
|
|
421
|
-
);
|
|
422
|
-
|
|
423
|
-
// TypeScript enforces valid field names:
|
|
424
|
-
setSort({ field: 'price', order: 'asc' });
|
|
425
|
-
```
|
|
426
|
-
|
|
427
|
-
---
|
|
428
|
-
|
|
429
|
-
### Clean URL Utility Helpers
|
|
430
|
-
|
|
431
|
-
`paramsync` exports two utility helpers used internally to enforce URL cleanliness:
|
|
432
|
-
|
|
433
|
-
```typescript
|
|
434
|
-
import { areValuesEqual, isCleanValue } from 'paramsync';
|
|
435
|
-
|
|
436
|
-
// Deep equality comparison supporting primitives, arrays, and nested objects
|
|
437
|
-
areValuesEqual({ min: 0, max: 100 }, { min: 0, max: 100 }); // true
|
|
438
|
-
|
|
439
|
-
// Clean URL evaluation helper
|
|
440
|
-
isCleanValue('', ''); // true
|
|
441
|
-
isCleanValue([], []); // true
|
|
442
|
-
isCleanValue(null); // true
|
|
443
|
-
isCleanValue(1, 1); // true (matches default)
|
|
444
|
-
isCleanValue(2, 1); // false
|
|
445
|
-
```
|
|
446
|
-
|
|
447
|
-
---
|
|
448
|
-
|
|
449
|
-
## 6. In-Depth Hooks Reference
|
|
450
|
-
|
|
451
|
-
### `useQueryState`
|
|
452
|
-
|
|
453
|
-
Manages an individual URL query parameter with optimistic state, automatic debouncing, and React transitions.
|
|
454
|
-
|
|
455
|
-
#### Function Signature & Type Overloads
|
|
456
|
-
|
|
457
|
-
```typescript
|
|
458
|
-
export function useQueryState<T>(
|
|
459
|
-
key: string,
|
|
460
|
-
parser: ParserWithDefault<T>,
|
|
461
|
-
options?: UseQueryStateOptions<T>
|
|
462
|
-
): readonly [
|
|
463
|
-
value: T,
|
|
464
|
-
setValue: (valueOrUpdater: StateUpdater<T>) => void,
|
|
465
|
-
reset: () => void,
|
|
466
|
-
isPending: boolean
|
|
467
|
-
];
|
|
468
|
-
|
|
469
|
-
export function useQueryState<T>(
|
|
470
|
-
key: string,
|
|
471
|
-
parser: Parser<T>,
|
|
472
|
-
options: UseQueryStateOptions<T> & { defaultValue: T }
|
|
473
|
-
): readonly [
|
|
474
|
-
value: T,
|
|
475
|
-
setValue: (valueOrUpdater: StateUpdater<T>) => void,
|
|
476
|
-
reset: () => void,
|
|
477
|
-
isPending: boolean
|
|
478
|
-
];
|
|
479
|
-
|
|
480
|
-
export function useQueryState<T>(
|
|
481
|
-
key: string,
|
|
482
|
-
parser: Parser<T>,
|
|
483
|
-
options?: UseQueryStateOptions<T>
|
|
484
|
-
): readonly [
|
|
485
|
-
value: T,
|
|
486
|
-
setValue: (valueOrUpdater: StateUpdater<T>) => void,
|
|
487
|
-
reset: () => void,
|
|
488
|
-
isPending: boolean
|
|
489
|
-
];
|
|
490
|
-
```
|
|
491
|
-
|
|
492
|
-
#### Options Reference
|
|
493
|
-
|
|
494
|
-
| Option | Type | Default | Description |
|
|
495
|
-
| :--- | :--- | :--- | :--- |
|
|
496
|
-
| `defaultValue` | `T` | `parser.defaultValue` | Fallback value when parameter is missing or unparseable. |
|
|
497
|
-
| `debounceMs` | `number` | `0` | Delay in milliseconds before updating URL. Local state updates at 0ms. |
|
|
498
|
-
| `history` | `'replace' \| 'push'` | `'replace'` | `'replace'` modifies URL in place; `'push'` creates a new browser history entry. |
|
|
499
|
-
| `scroll` | `boolean` | `false` | Whether Next.js should scroll to the top of the window on navigation. |
|
|
500
|
-
|
|
501
|
-
#### Return Values Reference
|
|
502
|
-
|
|
503
|
-
The hook returns a readonly 4-element tuple:
|
|
504
|
-
|
|
505
|
-
```tsx
|
|
506
|
-
const [value, setValue, reset, isPending] = useQueryState('q', parseAsString.withDefault(''));
|
|
507
|
-
```
|
|
508
|
-
|
|
509
|
-
1. **`value` (`T`):** The current typed value. Reflects the optimistic local state immediately.
|
|
510
|
-
2. **`setValue` (`(val | ((prev: T) => T)) => void`):** Updates internal state in `0ms` and schedules a debounced URL update.
|
|
511
|
-
3. **`reset` (`() => void`):** Reverts the internal state to `defaultValue` and deletes the parameter from the URL.
|
|
512
|
-
4. **`isPending` (`boolean`):** Indicates whether React's `useTransition` is actively synchronizing the URL navigation.
|
|
513
|
-
|
|
514
|
-
#### Functional State Updates
|
|
515
|
-
|
|
516
|
-
`setValue` supports functional updates just like `React.useState`:
|
|
517
|
-
|
|
518
|
-
```tsx
|
|
519
|
-
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
|
|
520
|
-
|
|
521
|
-
// Increment safely based on latest local state
|
|
522
|
-
setPage((current) => current + 1);
|
|
523
|
-
```
|
|
524
|
-
|
|
525
|
-
#### Reset Mechanics
|
|
526
|
-
|
|
527
|
-
Calling `reset()` immediately cancels any active debounce timer, restores the local state to default, and purges the key from the URL:
|
|
528
|
-
|
|
529
|
-
```tsx
|
|
530
|
-
const [search, setSearch, resetSearch] = useQueryState('q', parseAsString.withDefault(''));
|
|
531
|
-
|
|
532
|
-
<button onClick={resetSearch}>Reset Search</button>
|
|
533
|
-
```
|
|
534
|
-
|
|
535
|
-
---
|
|
536
|
-
|
|
537
|
-
### `useBatchQueryParams`
|
|
538
|
-
|
|
539
|
-
Executes multi-parameter updates simultaneously in a single navigation transition.
|
|
540
|
-
|
|
541
|
-
#### `useBatchQueryParams` Signature
|
|
542
|
-
|
|
543
|
-
```typescript
|
|
544
|
-
export function useBatchQueryParams(options?: UseBatchQueryParamsOptions): {
|
|
545
|
-
setBatchParams: (updates: BatchParamsRecord, navOptions?: BatchNavigationOptions) => void;
|
|
546
|
-
resetAllParams: (preserveKeys?: string[], navOptions?: BatchNavigationOptions) => void;
|
|
547
|
-
isPending: boolean;
|
|
548
|
-
};
|
|
549
|
-
```
|
|
550
|
-
|
|
551
|
-
#### `BatchParamDescriptor`
|
|
552
|
-
|
|
553
|
-
When calling `setBatchParams`, values can be passed directly or wrapped in a descriptor to specify a custom parser:
|
|
554
|
-
|
|
555
|
-
```typescript
|
|
556
|
-
export interface BatchParamDescriptor<T = any> {
|
|
557
|
-
value: T;
|
|
558
|
-
parser?: Parser<any>;
|
|
559
|
-
defaultValue?: T;
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
export type BatchParamValue =
|
|
563
|
-
| string
|
|
564
|
-
| number
|
|
565
|
-
| boolean
|
|
566
|
-
| null
|
|
567
|
-
| undefined
|
|
568
|
-
| unknown[]
|
|
569
|
-
| BatchParamDescriptor<any>;
|
|
570
|
-
|
|
571
|
-
export type BatchParamsRecord = Record<string, BatchParamValue>;
|
|
572
|
-
```
|
|
573
|
-
|
|
574
|
-
#### `setBatchParams`
|
|
575
|
-
|
|
576
|
-
Applies multiple parameter updates in a single URL navigation transition:
|
|
577
|
-
|
|
578
|
-
```tsx
|
|
579
|
-
const { setBatchParams } = useBatchQueryParams();
|
|
580
|
-
|
|
581
|
-
setBatchParams({
|
|
582
|
-
q: 'running shoes',
|
|
583
|
-
page: 1,
|
|
584
|
-
categories: {
|
|
585
|
-
value: ['shoes', 'outdoor'],
|
|
586
|
-
parser: parseAsArray(parseAsString),
|
|
587
|
-
},
|
|
588
|
-
price: {
|
|
589
|
-
value: { min: 50, max: 200 },
|
|
590
|
-
parser: parseAsNumberRange(0, 1000),
|
|
591
|
-
},
|
|
592
|
-
legacyParam: null, // Deletes 'legacyParam' from URL
|
|
593
|
-
});
|
|
594
|
-
```
|
|
595
|
-
|
|
596
|
-
#### `resetAllParams`
|
|
597
|
-
|
|
598
|
-
Clears all query parameters from the URL while preserving critical keys:
|
|
599
|
-
|
|
600
|
-
```tsx
|
|
601
|
-
const { resetAllParams } = useBatchQueryParams();
|
|
602
|
-
|
|
603
|
-
// Clears all filters but preserves analytics and tab indicators
|
|
604
|
-
resetAllParams(['utm_source', 'utm_medium', 'tab']);
|
|
605
|
-
```
|
|
606
|
-
|
|
607
|
-
---
|
|
608
|
-
|
|
609
|
-
## 7. Clean URL Policy: Complete Specification
|
|
610
|
-
|
|
611
|
-
### Decision Matrix Table
|
|
612
|
-
|
|
613
|
-
The following matrix specifies how `paramsync` decides whether to keep or remove a query parameter:
|
|
614
|
-
|
|
615
|
-
| Value State | Example | Parser Used | Result in URL Query String |
|
|
616
|
-
| :--- | :--- | :--- | :--- |
|
|
617
|
-
| **`null` or `undefined`** | `null` | Any | Deleted |
|
|
618
|
-
| **Empty string** | `""` | `parseAsString` | Deleted |
|
|
619
|
-
| **Empty array** | `[]` | `parseAsArray` | Deleted |
|
|
620
|
-
| **Matches default string** | `"all"` (default: `"all"`) | `parseAsString.withDefault("all")` | Deleted |
|
|
621
|
-
| **Matches default number** | `1` (default: `1`) | `parseAsInteger.withDefault(1)` | Deleted |
|
|
622
|
-
| **Matches default boolean** | `false` (default: `false`) | `parseAsBoolean.withDefault(false)` | Deleted |
|
|
623
|
-
| **Matches default range** | `{ min: 0, max: 1000 }` | `parseAsNumberRange(0, 1000)` | Deleted |
|
|
624
|
-
| **Matches default sort** | `{ field: "createdAt", order: "desc" }` | `parseAsSort("createdAt", "desc")` | Deleted |
|
|
625
|
-
| **Modified / Active Value** | `"running"` | `parseAsString` | Preserved: `?q=running` |
|
|
626
|
-
| **Active Number** | `3` | `parseAsInteger.withDefault(1)` | Preserved: `?page=3` |
|
|
627
|
-
| **Active Array** | `["shoes", "hats"]` | `parseAsArray(parseAsString)` | Preserved: `?tags=shoes,hats` |
|
|
628
|
-
| **Active Range** | `{ min: 50, max: 200 }` | `parseAsNumberRange(0, 1000)` | Preserved: `?price=50-200` |
|
|
629
|
-
|
|
630
|
-
### Why Clean URLs Matter
|
|
631
|
-
|
|
632
|
-
1. **Shareability:** When users share a link, only relevant filters are shared (e.g. `site.com/shoes?brand=nike` instead of `site.com/shoes?brand=nike&page=1&q=&price=0-1000&inStock=false`).
|
|
633
|
-
2. **SEO & Crawling:** Prevents search engines from indexing duplicate URL variations created by default parameters.
|
|
634
|
-
3. **Analytics:** Cleaner URL structures simplify URL grouping in web analytics tools.
|
|
635
|
-
|
|
636
|
-
---
|
|
637
|
-
|
|
638
|
-
## 8. Architecture & Data Fetching Patterns
|
|
639
|
-
|
|
640
|
-
### Pattern 1: Server Components (RSC) + Direct API Fetching
|
|
641
|
-
|
|
642
|
-
Because all parsers have zero DOM dependencies, you can import and execute them inside React Server Components:
|
|
643
|
-
|
|
644
|
-
```tsx
|
|
645
|
-
// app/products/page.tsx (Server Component)
|
|
646
|
-
import React from 'react';
|
|
647
|
-
import {
|
|
648
|
-
parseAsString,
|
|
649
|
-
parseAsInteger,
|
|
650
|
-
parseAsArray,
|
|
651
|
-
parseAsNumberRange,
|
|
652
|
-
} from 'paramsync';
|
|
653
|
-
import { FilterBar } from './FilterBar';
|
|
654
|
-
import { ProductGrid } from './ProductGrid';
|
|
655
|
-
|
|
656
|
-
interface PageProps {
|
|
657
|
-
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
export default async function ProductsPage({ searchParams }: PageProps) {
|
|
661
|
-
const params = await searchParams;
|
|
662
|
-
|
|
663
|
-
// 1. Parse incoming parameters on the server
|
|
664
|
-
const query = parseAsString.parse((params.q as string) ?? null) ?? '';
|
|
665
|
-
const page = parseAsInteger.withDefault(1).parse((params.page as string) ?? null);
|
|
666
|
-
const categories = parseAsArray(parseAsString).parse((params.categories as string) ?? null);
|
|
667
|
-
const price = parseAsNumberRange(0, 1000).parse((params.price as string) ?? null);
|
|
668
|
-
|
|
669
|
-
// 2. Build API query payload
|
|
670
|
-
const apiQuery = new URLSearchParams();
|
|
671
|
-
if (query) apiQuery.set('q', query);
|
|
672
|
-
if (page > 1) apiQuery.set('page', String(page));
|
|
673
|
-
if (categories.length > 0) apiQuery.set('categories', categories.join(','));
|
|
674
|
-
if (price.min > 0 || price.max < 1000) {
|
|
675
|
-
apiQuery.set('minPrice', String(price.min));
|
|
676
|
-
apiQuery.set('maxPrice', String(price.max));
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
// 3. Fetch from backend API
|
|
680
|
-
const res = await fetch(`https://api.example.com/products?${apiQuery.toString()}`, {
|
|
681
|
-
cache: 'no-store',
|
|
682
|
-
});
|
|
683
|
-
const data = await res.json();
|
|
684
|
-
|
|
685
|
-
return (
|
|
686
|
-
<div style={{ display: 'flex', gap: '2rem', padding: '2rem' }}>
|
|
687
|
-
<aside style={{ width: 280 }}>
|
|
688
|
-
<FilterBar />
|
|
689
|
-
</aside>
|
|
690
|
-
<main style={{ flex: 1 }}>
|
|
691
|
-
<ProductGrid items={data.items} total={data.total} page={page} />
|
|
692
|
-
</main>
|
|
693
|
-
</div>
|
|
694
|
-
);
|
|
695
|
-
}
|
|
696
|
-
```
|
|
697
|
-
|
|
698
|
-
---
|
|
699
|
-
|
|
700
|
-
### Pattern 2: Route Handlers & Server Actions
|
|
701
|
-
|
|
702
|
-
You can use parsers inside Next.js Route Handlers (`app/api/products/route.ts`):
|
|
703
|
-
|
|
704
|
-
```typescript
|
|
705
|
-
// app/api/products/route.ts
|
|
706
|
-
import { NextRequest, NextResponse } from 'next/server';
|
|
707
|
-
import { parseAsString, parseAsInteger, parseAsArray } from 'paramsync';
|
|
708
|
-
|
|
709
|
-
export async function GET(request: NextRequest) {
|
|
710
|
-
const { searchParams } = request.nextUrl;
|
|
711
|
-
|
|
712
|
-
const query = parseAsString.parse(searchParams.get('q')) ?? '';
|
|
713
|
-
const page = parseAsInteger.withDefault(1).parse(searchParams.get('page'));
|
|
714
|
-
const tags = parseAsArray(parseAsString).parse(searchParams.get('tags'));
|
|
715
|
-
|
|
716
|
-
// Execute database query with validated types
|
|
717
|
-
const data = await queryDatabase({ query, page, tags });
|
|
718
|
-
|
|
719
|
-
return NextResponse.json(data);
|
|
720
|
-
}
|
|
721
|
-
```
|
|
722
|
-
|
|
723
|
-
---
|
|
724
|
-
|
|
725
|
-
### Pattern 3: Client-Side with TanStack Query (React Query)
|
|
726
|
-
|
|
727
|
-
When fetching data on the client, bind `paramsync` state directly to your `queryKey`:
|
|
728
|
-
|
|
729
|
-
```tsx
|
|
730
|
-
'use client';
|
|
731
|
-
|
|
732
|
-
import React from 'react';
|
|
733
|
-
import { useQueryState, parseAsString, parseAsInteger, parseAsArray } from 'paramsync';
|
|
734
|
-
import { useQuery } from '@tanstack/react-query';
|
|
735
|
-
|
|
736
|
-
export function ClientProductFeed() {
|
|
47
|
+
export default function SearchFeed() {
|
|
48
|
+
// Live search with 300ms URL debounce (zero typing lag)
|
|
737
49
|
const [search, setSearch] = useQueryState('q', parseAsString.withDefault(''), {
|
|
738
50
|
debounceMs: 300,
|
|
739
51
|
});
|
|
740
|
-
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
|
|
741
|
-
const [categories] = useQueryState('categories', parseAsArray(parseAsString));
|
|
742
52
|
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
queryFn: async () => {
|
|
746
|
-
const params = new URLSearchParams();
|
|
747
|
-
if (search) params.set('q', search);
|
|
748
|
-
if (page > 1) params.set('page', String(page));
|
|
749
|
-
if (categories.length > 0) params.set('categories', categories.join(','));
|
|
750
|
-
|
|
751
|
-
const res = await fetch(`/api/products?${params.toString()}`);
|
|
752
|
-
if (!res.ok) throw new Error('Failed to load data');
|
|
753
|
-
return res.json();
|
|
754
|
-
},
|
|
755
|
-
placeholderData: (prev) => prev,
|
|
756
|
-
});
|
|
53
|
+
// Numeric page parameter
|
|
54
|
+
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
|
|
757
55
|
|
|
758
56
|
return (
|
|
759
57
|
<div>
|
|
@@ -761,13 +59,19 @@ export function ClientProductFeed() {
|
|
|
761
59
|
type="text"
|
|
762
60
|
value={search}
|
|
763
61
|
onChange={(e) => {
|
|
764
|
-
setPage(1);
|
|
62
|
+
setPage(1); // Reset page index on search
|
|
765
63
|
setSearch(e.target.value);
|
|
766
64
|
}}
|
|
767
|
-
placeholder="Filter
|
|
65
|
+
placeholder="Filter results..."
|
|
768
66
|
/>
|
|
769
67
|
|
|
770
|
-
{
|
|
68
|
+
<button disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
|
69
|
+
Prev
|
|
70
|
+
</button>
|
|
71
|
+
<span>Page {page}</span>
|
|
72
|
+
<button onClick={() => setPage((p) => p + 1)}>
|
|
73
|
+
Next
|
|
74
|
+
</button>
|
|
771
75
|
</div>
|
|
772
76
|
);
|
|
773
77
|
}
|
|
@@ -775,593 +79,75 @@ export function ClientProductFeed() {
|
|
|
775
79
|
|
|
776
80
|
---
|
|
777
81
|
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
```tsx
|
|
781
|
-
'use client';
|
|
782
|
-
|
|
783
|
-
import React from 'react';
|
|
784
|
-
import { useQueryState, parseAsString, parseAsInteger } from 'paramsync';
|
|
785
|
-
import useSWR from 'swr';
|
|
786
|
-
|
|
787
|
-
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
|
788
|
-
|
|
789
|
-
export function SWRProductFeed() {
|
|
790
|
-
const [search] = useQueryState('q', parseAsString.withDefault(''), { debounceMs: 300 });
|
|
791
|
-
const [page] = useQueryState('page', parseAsInteger.withDefault(1));
|
|
792
|
-
|
|
793
|
-
const queryString = new URLSearchParams({
|
|
794
|
-
...(search ? { q: search } : {}),
|
|
795
|
-
...(page > 1 ? { page: String(page) } : {}),
|
|
796
|
-
}).toString();
|
|
797
|
-
|
|
798
|
-
const { data, error, isValidating } = useSWR(
|
|
799
|
-
`/api/products${queryString ? `?${queryString}` : ''}`,
|
|
800
|
-
fetcher,
|
|
801
|
-
{ keepPreviousData: true }
|
|
802
|
-
);
|
|
803
|
-
|
|
804
|
-
return <div>{/* Render items */}</div>;
|
|
805
|
-
}
|
|
806
|
-
```
|
|
807
|
-
|
|
808
|
-
---
|
|
809
|
-
|
|
810
|
-
### Pattern 5: Native Fetch with useEffect
|
|
811
|
-
|
|
812
|
-
```tsx
|
|
813
|
-
'use client';
|
|
814
|
-
|
|
815
|
-
import React, { useState, useEffect } from 'react';
|
|
816
|
-
import { useQueryState, parseAsString, parseAsInteger } from 'paramsync';
|
|
817
|
-
|
|
818
|
-
export function NativeFetchCatalog() {
|
|
819
|
-
const [search] = useQueryState('q', parseAsString.withDefault(''), { debounceMs: 300 });
|
|
820
|
-
const [page] = useQueryState('page', parseAsInteger.withDefault(1));
|
|
821
|
-
|
|
822
|
-
const [items, setItems] = useState([]);
|
|
823
|
-
const [loading, setLoading] = useState(false);
|
|
824
|
-
|
|
825
|
-
useEffect(() => {
|
|
826
|
-
let ignore = false;
|
|
827
|
-
setLoading(true);
|
|
828
|
-
|
|
829
|
-
const query = new URLSearchParams();
|
|
830
|
-
if (search) query.set('q', search);
|
|
831
|
-
if (page > 1) query.set('page', String(page));
|
|
832
|
-
|
|
833
|
-
fetch(`/api/products?${query.toString()}`)
|
|
834
|
-
.then((res) => res.json())
|
|
835
|
-
.then((data) => {
|
|
836
|
-
if (!ignore) {
|
|
837
|
-
setItems(data.items);
|
|
838
|
-
setLoading(false);
|
|
839
|
-
}
|
|
840
|
-
});
|
|
841
|
-
|
|
842
|
-
return () => {
|
|
843
|
-
ignore = true;
|
|
844
|
-
};
|
|
845
|
-
}, [search, page]);
|
|
846
|
-
|
|
847
|
-
return <div>{/* Render items */}</div>;
|
|
848
|
-
}
|
|
849
|
-
```
|
|
850
|
-
|
|
851
|
-
---
|
|
852
|
-
|
|
853
|
-
## 9. Dynamic Facet Filters from API
|
|
854
|
-
|
|
855
|
-
### Dynamic Facet Architecture
|
|
856
|
-
|
|
857
|
-
In e-commerce catalogs, facets are often dynamic: the backend API returns attributes specific to the active category (e.g. `screen_size` for laptops, `shoe_size` for footwear, `color` for clothing).
|
|
858
|
-
|
|
859
|
-
Instead of declaring static variables for each potential facet, use `useBatchQueryParams` to bind arbitrary facet keys dynamically.
|
|
82
|
+
## API Summary
|
|
860
83
|
|
|
861
|
-
###
|
|
84
|
+
### Built-in Parsers
|
|
862
85
|
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
export interface FacetGroup {
|
|
876
|
-
id: string;
|
|
877
|
-
name: string;
|
|
878
|
-
options: FacetOption[];
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
interface DynamicFacetSidebarProps {
|
|
882
|
-
facets: FacetGroup[];
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
export function DynamicFacetSidebar({ facets }: DynamicFacetSidebarProps) {
|
|
886
|
-
const searchParams = useSearchParams();
|
|
887
|
-
const { setBatchParams, isPending } = useBatchQueryParams();
|
|
888
|
-
const arrayParser = parseAsArray(parseAsString);
|
|
889
|
-
|
|
890
|
-
const getSelectedOptions = (facetId: string): string[] => {
|
|
891
|
-
return arrayParser.parse(searchParams.get(facetId));
|
|
892
|
-
};
|
|
893
|
-
|
|
894
|
-
const handleToggleOption = (facetId: string, optionValue: string) => {
|
|
895
|
-
const selected = getSelectedOptions(facetId);
|
|
896
|
-
const updated = selected.includes(optionValue)
|
|
897
|
-
? selected.filter((item) => item !== optionValue)
|
|
898
|
-
: [...selected, optionValue];
|
|
899
|
-
|
|
900
|
-
setBatchParams({
|
|
901
|
-
// Clean URL: Deletes facet key if array becomes empty
|
|
902
|
-
[facetId]: updated.length > 0 ? updated.join(',') : null,
|
|
903
|
-
// Automatically reset page to 1
|
|
904
|
-
page: null,
|
|
905
|
-
});
|
|
906
|
-
};
|
|
907
|
-
|
|
908
|
-
return (
|
|
909
|
-
<aside style={{ opacity: isPending ? 0.6 : 1, transition: 'opacity 0.2s' }}>
|
|
910
|
-
{facets.map((group) => {
|
|
911
|
-
const selected = getSelectedOptions(group.id);
|
|
912
|
-
|
|
913
|
-
return (
|
|
914
|
-
<div key={group.id} style={{ marginBottom: '1.5rem' }}>
|
|
915
|
-
<h4 style={{ margin: '0 0 0.5rem 0' }}>{group.name}</h4>
|
|
916
|
-
{group.options.map((opt) => (
|
|
917
|
-
<label key={opt.value} style={{ display: 'block', margin: '0.25rem 0' }}>
|
|
918
|
-
<input
|
|
919
|
-
type="checkbox"
|
|
920
|
-
checked={selected.includes(opt.value)}
|
|
921
|
-
onChange={() => handleToggleOption(group.id, opt.value)}
|
|
922
|
-
/>{' '}
|
|
923
|
-
{opt.value} ({opt.count})
|
|
924
|
-
</label>
|
|
925
|
-
))}
|
|
926
|
-
</div>
|
|
927
|
-
);
|
|
928
|
-
})}
|
|
929
|
-
</aside>
|
|
930
|
-
);
|
|
931
|
-
}
|
|
932
|
-
```
|
|
933
|
-
|
|
934
|
-
---
|
|
935
|
-
|
|
936
|
-
## 10. Pagination Coordination & Stale Page Prevention
|
|
937
|
-
|
|
938
|
-
### The Stale Page Problem Explained
|
|
86
|
+
| Parser | Parse Signature | Clean URL Behavior | Example |
|
|
87
|
+
| :--- | :--- | :--- | :--- |
|
|
88
|
+
| `parseAsString` | `string \| null` | Omitted if `""` or default | `?q=laptop` |
|
|
89
|
+
| `parseAsInteger` | `number \| null` | Omitted if default | `?page=2` |
|
|
90
|
+
| `parseAsFloat` | `number \| null` | Omitted if default | `?rating=4.5` |
|
|
91
|
+
| `parseAsBoolean` | `boolean \| null` | Omitted if default | `?inStock=true` |
|
|
92
|
+
| `parseAsArray(parser, sep?)` | `T[]` | Omitted if `[]` or default | `?tags=shoes,sale` |
|
|
93
|
+
| `parseAsNumberRange(min, max, sep?)` | `{ min, max }` | Omitted if matches bounds | `?price=50-250` |
|
|
94
|
+
| `parseAsSort(field, order?, sep?)` | `{ field, order }` | Omitted if matches default | `?sort=price.asc` |
|
|
95
|
+
| `createParser(config)` | `T` | Custom serializer | User-defined |
|
|
939
96
|
|
|
940
|
-
|
|
941
|
-
1. A user browses to **Page 8** of an unfiltered catalog.
|
|
942
|
-
2. The user selects a specific filter: `brand=SpecialtyAudio`.
|
|
943
|
-
3. The filtered dataset contains only **12 products** (totaling 2 pages).
|
|
944
|
-
4. Because the URL still has `page=8`, the application queries the backend for page 8 and displays an empty screen with *"No results found"*.
|
|
97
|
+
> Calling `.withDefault(fallback)` on any parser narrows the returned state type from `T | null` to non-nullable `T`.
|
|
945
98
|
|
|
946
|
-
###
|
|
99
|
+
### Hooks
|
|
947
100
|
|
|
948
|
-
####
|
|
949
|
-
|
|
101
|
+
#### `useQueryState(key, parser, options?)`
|
|
102
|
+
Manages an individual URL parameter with optimistic local state.
|
|
950
103
|
|
|
951
104
|
```tsx
|
|
952
|
-
const [
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
setQuery(newQuery);
|
|
958
|
-
};
|
|
105
|
+
const [value, setValue, reset, isPending] = useQueryState('q', parseAsString.withDefault(''), {
|
|
106
|
+
debounceMs: 300, // Debounce URL commit (0ms local state feedback)
|
|
107
|
+
history: 'replace', // 'replace' | 'push'
|
|
108
|
+
scroll: false, // Scroll to top on update
|
|
109
|
+
});
|
|
959
110
|
```
|
|
960
111
|
|
|
961
|
-
####
|
|
962
|
-
|
|
112
|
+
#### `useBatchQueryParams(options?)`
|
|
113
|
+
Executes multiple parameter changes in a single URL transition (for modals or drawers):
|
|
963
114
|
|
|
964
115
|
```tsx
|
|
965
|
-
const { setBatchParams } = useBatchQueryParams();
|
|
116
|
+
const { setBatchParams, resetAllParams, isPending } = useBatchQueryParams();
|
|
966
117
|
|
|
118
|
+
// Commit multiple filter updates in a single transition
|
|
967
119
|
setBatchParams({
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
120
|
+
q: 'shoes',
|
|
121
|
+
categories: { value: ['running'], parser: parseAsArray(parseAsString) },
|
|
122
|
+
price: { value: { min: 50, max: 200 }, parser: parseAsNumberRange(0, 1000) },
|
|
123
|
+
page: null, // Deletes 'page' from URL (resets to page 1)
|
|
971
124
|
});
|
|
972
|
-
```
|
|
973
|
-
|
|
974
|
-
---
|
|
975
|
-
|
|
976
|
-
## 11. Complete Production Recipes
|
|
977
|
-
|
|
978
|
-
### Recipe 1: Instant Reactive E-Commerce Filter Bar
|
|
979
|
-
|
|
980
|
-
```tsx
|
|
981
|
-
'use client';
|
|
982
|
-
|
|
983
|
-
import React from 'react';
|
|
984
|
-
import {
|
|
985
|
-
useQueryState,
|
|
986
|
-
parseAsString,
|
|
987
|
-
parseAsInteger,
|
|
988
|
-
parseAsBoolean,
|
|
989
|
-
parseAsArray,
|
|
990
|
-
parseAsSort,
|
|
991
|
-
} from 'paramsync';
|
|
992
|
-
|
|
993
|
-
type SortField = 'price' | 'rating' | 'createdAt';
|
|
994
|
-
const CATEGORIES = ['Footwear', 'Apparel', 'Accessories', 'Electronics'];
|
|
995
|
-
|
|
996
|
-
export function InstantFilterBar() {
|
|
997
|
-
// Live Search with 300ms debounce
|
|
998
|
-
const [search, setSearch, resetSearch, isSearchPending] = useQueryState(
|
|
999
|
-
'q',
|
|
1000
|
-
parseAsString.withDefault(''),
|
|
1001
|
-
{ debounceMs: 300 }
|
|
1002
|
-
);
|
|
1003
125
|
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
// Multi-select categories
|
|
1008
|
-
const [categories, setCategories] = useQueryState('categories', parseAsArray(parseAsString));
|
|
1009
|
-
|
|
1010
|
-
// Boolean in-stock toggle
|
|
1011
|
-
const [inStock, setInStock] = useQueryState('inStock', parseAsBoolean.withDefault(false));
|
|
1012
|
-
|
|
1013
|
-
// Sort dropdown
|
|
1014
|
-
const [sort, setSort] = useQueryState('sort', parseAsSort<SortField>('createdAt', 'desc'));
|
|
1015
|
-
|
|
1016
|
-
const toggleCategory = (cat: string) => {
|
|
1017
|
-
setPage(1);
|
|
1018
|
-
setCategories((prev) =>
|
|
1019
|
-
prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat]
|
|
1020
|
-
);
|
|
1021
|
-
};
|
|
1022
|
-
|
|
1023
|
-
return (
|
|
1024
|
-
<div style={{ maxWidth: 800, margin: '0 auto', fontFamily: 'sans-serif' }}>
|
|
1025
|
-
{/* Search Input */}
|
|
1026
|
-
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
|
|
1027
|
-
<input
|
|
1028
|
-
type="text"
|
|
1029
|
-
value={search}
|
|
1030
|
-
onChange={(e) => {
|
|
1031
|
-
setPage(1);
|
|
1032
|
-
setSearch(e.target.value);
|
|
1033
|
-
}}
|
|
1034
|
-
placeholder="Search items..."
|
|
1035
|
-
style={{ flex: 1, padding: '0.5rem' }}
|
|
1036
|
-
/>
|
|
1037
|
-
{search && <button onClick={resetSearch}>Clear</button>}
|
|
1038
|
-
{isSearchPending && <span>Syncing...</span>}
|
|
1039
|
-
</div>
|
|
1040
|
-
|
|
1041
|
-
{/* Categories */}
|
|
1042
|
-
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
|
|
1043
|
-
{CATEGORIES.map((cat) => (
|
|
1044
|
-
<button
|
|
1045
|
-
key={cat}
|
|
1046
|
-
onClick={() => toggleCategory(cat)}
|
|
1047
|
-
style={{
|
|
1048
|
-
padding: '0.35rem 0.75rem',
|
|
1049
|
-
fontWeight: categories.includes(cat) ? 'bold' : 'normal',
|
|
1050
|
-
}}
|
|
1051
|
-
>
|
|
1052
|
-
{cat}
|
|
1053
|
-
</button>
|
|
1054
|
-
))}
|
|
1055
|
-
</div>
|
|
1056
|
-
|
|
1057
|
-
{/* In-Stock & Sorting */}
|
|
1058
|
-
<div style={{ display: 'flex', gap: '2rem', alignItems: 'center', marginBottom: '1rem' }}>
|
|
1059
|
-
<label>
|
|
1060
|
-
<input
|
|
1061
|
-
type="checkbox"
|
|
1062
|
-
checked={inStock}
|
|
1063
|
-
onChange={(e) => {
|
|
1064
|
-
setPage(1);
|
|
1065
|
-
setInStock(e.target.checked);
|
|
1066
|
-
}}
|
|
1067
|
-
/>{' '}
|
|
1068
|
-
In Stock Only
|
|
1069
|
-
</label>
|
|
1070
|
-
|
|
1071
|
-
<div>
|
|
1072
|
-
<label>Sort by: </label>
|
|
1073
|
-
<select
|
|
1074
|
-
value={`${sort.field}.${sort.order}`}
|
|
1075
|
-
onChange={(e) => {
|
|
1076
|
-
const [field, order] = e.target.value.split('.') as [SortField, 'asc' | 'desc'];
|
|
1077
|
-
setSort({ field, order });
|
|
1078
|
-
}}
|
|
1079
|
-
>
|
|
1080
|
-
<option value="createdAt.desc">Newest Arrivals</option>
|
|
1081
|
-
<option value="price.asc">Price: Low to High</option>
|
|
1082
|
-
<option value="price.desc">Price: High to Low</option>
|
|
1083
|
-
<option value="rating.desc">Highest Rated</option>
|
|
1084
|
-
</select>
|
|
1085
|
-
</div>
|
|
1086
|
-
</div>
|
|
1087
|
-
|
|
1088
|
-
{/* Pagination Controls */}
|
|
1089
|
-
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
|
1090
|
-
<button disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
|
1091
|
-
Previous
|
|
1092
|
-
</button>
|
|
1093
|
-
<span>Page {page}</span>
|
|
1094
|
-
<button onClick={() => setPage((p) => p + 1)}>Next</button>
|
|
1095
|
-
</div>
|
|
1096
|
-
</div>
|
|
1097
|
-
);
|
|
1098
|
-
}
|
|
126
|
+
// Wipe all non-essential parameters at once
|
|
127
|
+
resetAllParams(['tab', 'utm_source']);
|
|
1099
128
|
```
|
|
1100
129
|
|
|
1101
130
|
---
|
|
1102
131
|
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
```tsx
|
|
1106
|
-
'use client';
|
|
1107
|
-
|
|
1108
|
-
import React, { useState } from 'react';
|
|
1109
|
-
import { useSearchParams } from 'next/navigation';
|
|
1110
|
-
import {
|
|
1111
|
-
useBatchQueryParams,
|
|
1112
|
-
parseAsNumberRange,
|
|
1113
|
-
parseAsArray,
|
|
1114
|
-
parseAsString,
|
|
1115
|
-
NumberRange,
|
|
1116
|
-
} from 'paramsync';
|
|
1117
|
-
|
|
1118
|
-
export function FilterDrawerModal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
|
|
1119
|
-
const searchParams = useSearchParams();
|
|
1120
|
-
const { setBatchParams, resetAllParams, isPending } = useBatchQueryParams();
|
|
1121
|
-
|
|
1122
|
-
const priceParser = parseAsNumberRange(0, 1000);
|
|
1123
|
-
const arrayParser = parseAsArray(parseAsString);
|
|
1124
|
-
|
|
1125
|
-
// Buffer draft state locally
|
|
1126
|
-
const [draftSearch, setDraftSearch] = useState(searchParams.get('q') ?? '');
|
|
1127
|
-
const [draftCategories, setDraftCategories] = useState<string[]>(
|
|
1128
|
-
arrayParser.parse(searchParams.get('categories'))
|
|
1129
|
-
);
|
|
1130
|
-
const [draftPrice, setDraftPrice] = useState<NumberRange>(
|
|
1131
|
-
priceParser.parse(searchParams.get('price'))
|
|
1132
|
-
);
|
|
1133
|
-
|
|
1134
|
-
if (!isOpen) return null;
|
|
1135
|
-
|
|
1136
|
-
const handleApply = () => {
|
|
1137
|
-
setBatchParams({
|
|
1138
|
-
q: draftSearch,
|
|
1139
|
-
categories: { value: draftCategories, parser: arrayParser },
|
|
1140
|
-
price: { value: draftPrice, parser: priceParser },
|
|
1141
|
-
page: null, // Reset to page 1
|
|
1142
|
-
});
|
|
1143
|
-
onClose();
|
|
1144
|
-
};
|
|
1145
|
-
|
|
1146
|
-
const handleResetAll = () => {
|
|
1147
|
-
resetAllParams(['tab', 'utm_source']);
|
|
1148
|
-
setDraftSearch('');
|
|
1149
|
-
setDraftCategories([]);
|
|
1150
|
-
setDraftPrice({ min: 0, max: 1000 });
|
|
1151
|
-
onClose();
|
|
1152
|
-
};
|
|
132
|
+
## Clean URL Policy
|
|
1153
133
|
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
return (
|
|
1161
|
-
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', justifyContent: 'flex-end' }}>
|
|
1162
|
-
<div style={{ width: 320, background: '#fff', padding: '2rem', display: 'flex', flexDirection: 'column' }}>
|
|
1163
|
-
<h3>Filter Items</h3>
|
|
1164
|
-
|
|
1165
|
-
<div style={{ marginBottom: '1rem' }}>
|
|
1166
|
-
<label>Keyword</label>
|
|
1167
|
-
<input
|
|
1168
|
-
type="text"
|
|
1169
|
-
value={draftSearch}
|
|
1170
|
-
onChange={(e) => setDraftSearch(e.target.value)}
|
|
1171
|
-
style={{ width: '100%' }}
|
|
1172
|
-
/>
|
|
1173
|
-
</div>
|
|
1174
|
-
|
|
1175
|
-
<div style={{ marginBottom: '1rem' }}>
|
|
1176
|
-
<label>Categories</label>
|
|
1177
|
-
{['Laptops', 'Audio', 'Monitors'].map((cat) => (
|
|
1178
|
-
<label key={cat} style={{ display: 'block' }}>
|
|
1179
|
-
<input
|
|
1180
|
-
type="checkbox"
|
|
1181
|
-
checked={draftCategories.includes(cat)}
|
|
1182
|
-
onChange={() => toggleCategory(cat)}
|
|
1183
|
-
/>{' '}
|
|
1184
|
-
{cat}
|
|
1185
|
-
</label>
|
|
1186
|
-
))}
|
|
1187
|
-
</div>
|
|
1188
|
-
|
|
1189
|
-
<div style={{ marginBottom: '1.5rem' }}>
|
|
1190
|
-
<label>Max Price: ${draftPrice.max}</label>
|
|
1191
|
-
<input
|
|
1192
|
-
type="range"
|
|
1193
|
-
min={0}
|
|
1194
|
-
max={1000}
|
|
1195
|
-
value={draftPrice.max}
|
|
1196
|
-
onChange={(e) => setDraftPrice({ min: draftPrice.min, max: Number(e.target.value) })}
|
|
1197
|
-
style={{ width: '100%' }}
|
|
1198
|
-
/>
|
|
1199
|
-
</div>
|
|
1200
|
-
|
|
1201
|
-
<div style={{ marginTop: 'auto', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
|
1202
|
-
<button onClick={handleApply} disabled={isPending}>
|
|
1203
|
-
{isPending ? 'Applying...' : 'Apply Filters'}
|
|
1204
|
-
</button>
|
|
1205
|
-
<button onClick={handleResetAll}>Reset All</button>
|
|
1206
|
-
<button onClick={onClose}>Cancel</button>
|
|
1207
|
-
</div>
|
|
1208
|
-
</div>
|
|
1209
|
-
</div>
|
|
1210
|
-
);
|
|
1211
|
-
}
|
|
1212
|
-
```
|
|
134
|
+
`paramsync` automatically deletes a parameter from the query string when:
|
|
135
|
+
1. Its value equals `defaultValue`.
|
|
136
|
+
2. Its value is an empty string `""`.
|
|
137
|
+
3. Its value is an empty array `[]`.
|
|
138
|
+
4. Its value is `null` or `undefined`.
|
|
1213
139
|
|
|
1214
140
|
---
|
|
1215
141
|
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
```tsx
|
|
1219
|
-
'use client';
|
|
142
|
+
## Documentation & Repository
|
|
1220
143
|
|
|
1221
|
-
|
|
1222
|
-
import { useQueryState, parseAsSort, parseAsInteger } from 'paramsync';
|
|
144
|
+
For complete architectural guides, Server Components (RSC) data fetching patterns, TanStack Query integration, dynamic facet filtering, and recipes:
|
|
1223
145
|
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
export function UserDataTable() {
|
|
1227
|
-
const [sort, setSort] = useQueryState('sort', parseAsSort<UserField>('createdAt', 'desc'));
|
|
1228
|
-
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
|
|
1229
|
-
const [pageSize, setPageSize] = useQueryState('limit', parseAsInteger.withDefault(10));
|
|
1230
|
-
|
|
1231
|
-
const handleHeaderClick = (field: UserField) => {
|
|
1232
|
-
setSort((current) => ({
|
|
1233
|
-
field,
|
|
1234
|
-
order: current.field === field && current.order === 'asc' ? 'desc' : 'asc',
|
|
1235
|
-
}));
|
|
1236
|
-
};
|
|
1237
|
-
|
|
1238
|
-
return (
|
|
1239
|
-
<div>
|
|
1240
|
-
<table>
|
|
1241
|
-
<thead>
|
|
1242
|
-
<tr>
|
|
1243
|
-
<th onClick={() => handleHeaderClick('name')} style={{ cursor: 'pointer' }}>
|
|
1244
|
-
Name {sort.field === 'name' ? (sort.order === 'asc' ? '▲' : '▼') : ''}
|
|
1245
|
-
</th>
|
|
1246
|
-
<th onClick={() => handleHeaderClick('email')} style={{ cursor: 'pointer' }}>
|
|
1247
|
-
Email {sort.field === 'email' ? (sort.order === 'asc' ? '▲' : '▼') : ''}
|
|
1248
|
-
</th>
|
|
1249
|
-
<th onClick={() => handleHeaderClick('role')} style={{ cursor: 'pointer' }}>
|
|
1250
|
-
Role {sort.field === 'role' ? (sort.order === 'asc' ? '▲' : '▼') : ''}
|
|
1251
|
-
</th>
|
|
1252
|
-
</tr>
|
|
1253
|
-
</thead>
|
|
1254
|
-
<tbody>{/* Table rows */}</tbody>
|
|
1255
|
-
</table>
|
|
1256
|
-
|
|
1257
|
-
{/* Pagination Bar */}
|
|
1258
|
-
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center', marginTop: '1rem' }}>
|
|
1259
|
-
<button disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
|
1260
|
-
Previous
|
|
1261
|
-
</button>
|
|
1262
|
-
<span>Page {page}</span>
|
|
1263
|
-
<button onClick={() => setPage((p) => p + 1)}>Next</button>
|
|
1264
|
-
|
|
1265
|
-
<select
|
|
1266
|
-
value={pageSize}
|
|
1267
|
-
onChange={(e) => {
|
|
1268
|
-
setPage(1);
|
|
1269
|
-
setPageSize(Number(e.target.value));
|
|
1270
|
-
}}
|
|
1271
|
-
>
|
|
1272
|
-
<option value={10}>10 per page</option>
|
|
1273
|
-
<option value={25}>25 per page</option>
|
|
1274
|
-
<option value={50}>50 per page</option>
|
|
1275
|
-
</select>
|
|
1276
|
-
</div>
|
|
1277
|
-
</div>
|
|
1278
|
-
);
|
|
1279
|
-
}
|
|
1280
|
-
```
|
|
1281
|
-
|
|
1282
|
-
---
|
|
1283
|
-
|
|
1284
|
-
## 12. TypeScript System & Type Narrowing
|
|
1285
|
-
|
|
1286
|
-
`paramsync` is authored in TypeScript 5+ with strict type checking:
|
|
1287
|
-
|
|
1288
|
-
### Automatic Nullability Narrowing
|
|
1289
|
-
|
|
1290
|
-
```typescript
|
|
1291
|
-
// 1. Without withDefault: Type is `string | null`
|
|
1292
|
-
const [nullableValue] = useQueryState('q', parseAsString);
|
|
1293
|
-
|
|
1294
|
-
// 2. With withDefault: Type narrows to non-nullable `string`
|
|
1295
|
-
const [stringValue] = useQueryState('q', parseAsString.withDefault(''));
|
|
1296
|
-
|
|
1297
|
-
// 3. With options.defaultValue: Type narrows to non-nullable `string`
|
|
1298
|
-
const [optionsValue] = useQueryState('q', parseAsString, { defaultValue: '' });
|
|
1299
|
-
```
|
|
1300
|
-
|
|
1301
|
-
### Strict Field Autocomplete in Sorting
|
|
1302
|
-
|
|
1303
|
-
```typescript
|
|
1304
|
-
type AllowedColumns = 'id' | 'title' | 'author';
|
|
1305
|
-
|
|
1306
|
-
const [sort, setSort] = useQueryState('sort', parseAsSort<AllowedColumns>('id', 'asc'));
|
|
1307
|
-
|
|
1308
|
-
// Autocomplete and type checking:
|
|
1309
|
-
setSort({ field: 'title', order: 'desc' }); // Valid
|
|
1310
|
-
// @ts-expect-error Argument of type '"unknown"' is not assignable to 'AllowedColumns'
|
|
1311
|
-
setSort({ field: 'unknown', order: 'asc' });
|
|
1312
|
-
```
|
|
1313
|
-
|
|
1314
|
-
---
|
|
1315
|
-
|
|
1316
|
-
## 13. Next.js App Router Best Practices & Gotchas
|
|
1317
|
-
|
|
1318
|
-
### Suspense Boundary Requirement
|
|
1319
|
-
|
|
1320
|
-
Next.js App Router requires client components reading search parameters via `useSearchParams()` to be wrapped in a `<Suspense>` boundary when statically generating pages during `next build`:
|
|
1321
|
-
|
|
1322
|
-
```tsx
|
|
1323
|
-
// app/products/page.tsx
|
|
1324
|
-
import { Suspense } from 'react';
|
|
1325
|
-
import ProductCatalog from './ProductCatalog';
|
|
1326
|
-
|
|
1327
|
-
export default function Page() {
|
|
1328
|
-
return (
|
|
1329
|
-
<Suspense fallback={<div>Loading catalog...</div>}>
|
|
1330
|
-
<ProductCatalog />
|
|
1331
|
-
</Suspense>
|
|
1332
|
-
);
|
|
1333
|
-
}
|
|
1334
|
-
```
|
|
1335
|
-
|
|
1336
|
-
If you omit `<Suspense>`, Next.js will de-opt the entire page to client-side rendering at runtime with a warning.
|
|
1337
|
-
|
|
1338
|
-
### Browser History vs In-Place Updates
|
|
1339
|
-
|
|
1340
|
-
- Use `history: 'replace'` (the default) for search inputs, range sliders, and active filtering to avoid cluttering the browser's Back button history.
|
|
1341
|
-
- Use `history: 'push'` for pagination or multi-step wizard views where users expect the browser's Back button to navigate to the previous page index.
|
|
1342
|
-
|
|
1343
|
-
---
|
|
1344
|
-
|
|
1345
|
-
## 14. Testing & Verification
|
|
1346
|
-
|
|
1347
|
-
The test suite is built on Vitest and React Testing Library, running in a JSDOM environment:
|
|
1348
|
-
|
|
1349
|
-
```bash
|
|
1350
|
-
# Run complete test suite once
|
|
1351
|
-
npm test
|
|
1352
|
-
|
|
1353
|
-
# Run tests in watch mode
|
|
1354
|
-
npm run test:watch
|
|
1355
|
-
|
|
1356
|
-
# Verify TypeScript type definitions without building
|
|
1357
|
-
npm run typecheck
|
|
1358
|
-
|
|
1359
|
-
# Execute full prepublish workflow (typecheck + test + tsup dual build)
|
|
1360
|
-
npm run prepublishOnly
|
|
1361
|
-
```
|
|
146
|
+
- **Repository & Full Docs:** [https://github.com/3b3zeem/paramsync](https://github.com/3b3zeem/paramsync)
|
|
147
|
+
- **Issues & Feedback:** [https://github.com/3b3zeem/paramsync/issues](https://github.com/3b3zeem/paramsync/issues)
|
|
1362
148
|
|
|
1363
149
|
---
|
|
1364
150
|
|
|
1365
|
-
##
|
|
151
|
+
## License
|
|
1366
152
|
|
|
1367
|
-
MIT © [
|
|
153
|
+
MIT © [Ahmed Mostafa](https://github.com/3b3zeem/paramsync)
|