paramsync 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,1367 @@
1
- # Temporary Holding Version
1
+ # paramsync
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5+-blue.svg)](https://www.typescriptlang.org/)
7
+ [![Dual ESM/CJS](https://img.shields.io/badge/Module-Dual%20ESM%2FCJS-orange.svg)](#)
8
+ [![Next.js 13/14/15/16](https://img.shields.io/badge/Next.js-App%20Router-black.svg)](https://nextjs.org/)
9
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](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)