paramsync 0.0.0-stage → 1.0.1
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 +1366 -2
- package/dist/index.cjs +392 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +180 -0
- package/dist/index.d.ts +180 -0
- package/dist/index.js +379 -0
- package/dist/index.js.map +1 -0
- package/package.json +78 -4
package/README.md
CHANGED
|
@@ -1,3 +1,1367 @@
|
|
|
1
|
-
#
|
|
1
|
+
# paramsync
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> **Zero-overhead, type-safe URL Search Params state manager for React 18+ and Next.js (>=13.0.0 App Router).**
|
|
4
|
+
> Designed for live search inputs, multi-attribute facet filtering, price/metric range sliders, data table sorting, pagination, and modal filter drawers.
|
|
5
|
+
|
|
6
|
+
[](https://www.typescriptlang.org/)
|
|
7
|
+
[](#)
|
|
8
|
+
[](https://nextjs.org/)
|
|
9
|
+
[](LICENSE)
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Table of Contents
|
|
14
|
+
|
|
15
|
+
1. [Overview & Key Advantages](#1-overview--key-advantages)
|
|
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)
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## 1. Overview & Key Advantages
|
|
71
|
+
|
|
72
|
+
Binding UI state to URL query parameters is essential for building modern web applications where search, filters, sorting, and pagination are bookmarkable and shareable. However, standard solutions often cause keystroke latency, excessive URL clutter, race conditions, and heavy boilerplate.
|
|
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`.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## 2. Installation & Requirements
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
npm install paramsync
|
|
91
|
+
# or
|
|
92
|
+
pnpm add paramsync
|
|
93
|
+
# or
|
|
94
|
+
yarn add paramsync
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Peer Dependencies
|
|
98
|
+
- `react`: `>=18.0.0`
|
|
99
|
+
- `next`: `>=13.0.0` (App Router)
|
|
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). |
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## 4. Quick Start Tutorial
|
|
131
|
+
|
|
132
|
+
Here is a minimal, fully functioning client component showing live search with debounce and pagination:
|
|
133
|
+
|
|
134
|
+
```tsx
|
|
135
|
+
'use client';
|
|
136
|
+
|
|
137
|
+
import React from 'react';
|
|
138
|
+
import { useQueryState, parseAsString, parseAsInteger } from 'paramsync';
|
|
139
|
+
|
|
140
|
+
export default function ProductCatalog() {
|
|
141
|
+
// 1. Live search with 300ms debounce
|
|
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() {
|
|
737
|
+
const [search, setSearch] = useQueryState('q', parseAsString.withDefault(''), {
|
|
738
|
+
debounceMs: 300,
|
|
739
|
+
});
|
|
740
|
+
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
|
|
741
|
+
const [categories] = useQueryState('categories', parseAsArray(parseAsString));
|
|
742
|
+
|
|
743
|
+
const { data, isLoading, isPlaceholderData } = useQuery({
|
|
744
|
+
queryKey: ['products', { search, page, categories }],
|
|
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
|
+
});
|
|
757
|
+
|
|
758
|
+
return (
|
|
759
|
+
<div>
|
|
760
|
+
<input
|
|
761
|
+
type="text"
|
|
762
|
+
value={search}
|
|
763
|
+
onChange={(e) => {
|
|
764
|
+
setPage(1);
|
|
765
|
+
setSearch(e.target.value);
|
|
766
|
+
}}
|
|
767
|
+
placeholder="Filter products..."
|
|
768
|
+
/>
|
|
769
|
+
|
|
770
|
+
{isLoading ? <p>Loading...</p> : <div>{/* Render items */}</div>}
|
|
771
|
+
</div>
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
```
|
|
775
|
+
|
|
776
|
+
---
|
|
777
|
+
|
|
778
|
+
### Pattern 4: Client-Side with SWR
|
|
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.
|
|
860
|
+
|
|
861
|
+
### Complete Dynamic Facet Component
|
|
862
|
+
|
|
863
|
+
```tsx
|
|
864
|
+
'use client';
|
|
865
|
+
|
|
866
|
+
import React from 'react';
|
|
867
|
+
import { useSearchParams } from 'next/navigation';
|
|
868
|
+
import { useBatchQueryParams, parseAsArray, parseAsString } from 'paramsync';
|
|
869
|
+
|
|
870
|
+
export interface FacetOption {
|
|
871
|
+
value: string;
|
|
872
|
+
count: number;
|
|
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
|
|
939
|
+
|
|
940
|
+
A common bug in filtering interfaces is the **stale page index**:
|
|
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"*.
|
|
945
|
+
|
|
946
|
+
### Auto-Reset Solution Patterns
|
|
947
|
+
|
|
948
|
+
#### Solution A: Single Hook (`useQueryState`)
|
|
949
|
+
Reset page to `1` when triggering filter updates:
|
|
950
|
+
|
|
951
|
+
```tsx
|
|
952
|
+
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
|
|
953
|
+
const [query, setQuery] = useQueryState('q', parseAsString.withDefault(''), { debounceMs: 300 });
|
|
954
|
+
|
|
955
|
+
const handleSearch = (newQuery: string) => {
|
|
956
|
+
setPage(1); // Cleans 'page=1' from URL automatically
|
|
957
|
+
setQuery(newQuery);
|
|
958
|
+
};
|
|
959
|
+
```
|
|
960
|
+
|
|
961
|
+
#### Solution B: Batch Updates (`useBatchQueryParams`)
|
|
962
|
+
Pass `page: null` in the batch payload. This deletes `page` from the URL in the exact same transition as the filter changes:
|
|
963
|
+
|
|
964
|
+
```tsx
|
|
965
|
+
const { setBatchParams } = useBatchQueryParams();
|
|
966
|
+
|
|
967
|
+
setBatchParams({
|
|
968
|
+
brand: 'Sony',
|
|
969
|
+
price: '100-300',
|
|
970
|
+
page: null, // Deletes 'page' parameter from URL
|
|
971
|
+
});
|
|
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
|
+
|
|
1004
|
+
// Pagination
|
|
1005
|
+
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
|
|
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
|
+
}
|
|
1099
|
+
```
|
|
1100
|
+
|
|
1101
|
+
---
|
|
1102
|
+
|
|
1103
|
+
### Recipe 2: Filter Drawer / Modal with Apply & Reset All
|
|
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
|
+
};
|
|
1153
|
+
|
|
1154
|
+
const toggleCategory = (cat: string) => {
|
|
1155
|
+
setDraftCategories((prev) =>
|
|
1156
|
+
prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat]
|
|
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
|
+
```
|
|
1213
|
+
|
|
1214
|
+
---
|
|
1215
|
+
|
|
1216
|
+
### Recipe 3: Data Table with Sorting & Dynamic Page Size
|
|
1217
|
+
|
|
1218
|
+
```tsx
|
|
1219
|
+
'use client';
|
|
1220
|
+
|
|
1221
|
+
import React from 'react';
|
|
1222
|
+
import { useQueryState, parseAsSort, parseAsInteger } from 'paramsync';
|
|
1223
|
+
|
|
1224
|
+
type UserField = 'name' | 'email' | 'role' | 'createdAt';
|
|
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
|
+
```
|
|
1362
|
+
|
|
1363
|
+
---
|
|
1364
|
+
|
|
1365
|
+
## 15. License
|
|
1366
|
+
|
|
1367
|
+
MIT © [paramsync](https://github.com/paramsync/paramsync)
|