paramsync 1.0.1 → 1.0.2

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.
Files changed (3) hide show
  1. package/README.md +72 -1286
  2. package/README.npm.md +153 -0
  3. package/package.json +4 -3
package/README.md CHANGED
@@ -1,90 +1,27 @@
1
1
  # paramsync
2
2
 
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.
3
+ Type-safe, zero-overhead URL Search Parameters state manager for React and Next.js App Router applications.
5
4
 
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)
5
+ [![npm version](https://img.shields.io/npm/v/paramsync.svg?style=flat&colorA=18181B&colorB=2563EB)](https://www.npmjs.com/package/paramsync)
6
+ [![npm downloads](https://img.shields.io/npm/dm/paramsync.svg?style=flat&colorA=18181B&colorB=2563EB)](https://www.npmjs.com/package/paramsync)
7
+ [![bundle size](https://img.shields.io/bundlephobia/minzip/paramsync?style=flat&colorA=18181B&colorB=2563EB)](https://bundlephobia.com/package/paramsync)
8
+ [![license](https://img.shields.io/npm/l/paramsync.svg?style=flat&colorA=18181B&colorB=2563EB)](https://github.com/3b3zeem/paramsync)
10
9
 
11
10
  ---
12
11
 
13
- ## Table of Contents
12
+ ## What is paramsync?
14
13
 
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)
14
+ In Next.js App Router applications, managing state through URL search parameters using standard `useSearchParams()` and `router.replace()` often introduces keystroke latency during live search, UI stutter, and cluttered URLs filled with empty or default values.
67
15
 
68
- ---
69
-
70
- ## 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`.
16
+ `paramsync` provides a seamless `[value, setValue]` state hook that binds React state directly to the URL:
17
+ - **0ms Input Latency:** Controlled inputs update local state instantaneously while background URL updates are debounced.
18
+ - **Clean URL Policy:** Default values, empty strings, and empty arrays are automatically purged from the query string.
19
+ - **Batch Transitions:** Multiple filter changes can be committed simultaneously in a single navigation transition via `useBatchQueryParams`.
20
+ - **Server Component Compatible:** Parsers have zero DOM dependencies and execute safely in React Server Components and Route Handlers.
84
21
 
85
22
  ---
86
23
 
87
- ## 2. Installation & Requirements
24
+ ## Installation
88
25
 
89
26
  ```bash
90
27
  npm install paramsync
@@ -95,665 +32,26 @@ yarn add paramsync
95
32
  ```
96
33
 
97
34
  ### Peer Dependencies
98
- - `react`: `>=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). |
35
+ - `react` >= 18.0.0
36
+ - `next` >= 13.0.0 (App Router)
127
37
 
128
38
  ---
129
39
 
130
- ## 4. Quick Start Tutorial
131
-
132
- Here is a minimal, fully functioning client component showing live search with debounce and pagination:
40
+ ## Quick Example
133
41
 
134
42
  ```tsx
135
43
  'use client';
136
44
 
137
- import React from 'react';
138
45
  import { useQueryState, parseAsString, parseAsInteger } from 'paramsync';
139
46
 
140
- export default function 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() {
47
+ export default function SearchFeed() {
48
+ // Live search with 300ms URL debounce (zero typing lag)
737
49
  const [search, setSearch] = useQueryState('q', parseAsString.withDefault(''), {
738
50
  debounceMs: 300,
739
51
  });
740
- const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
741
- const [categories] = useQueryState('categories', parseAsArray(parseAsString));
742
52
 
743
- 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
- });
53
+ // Numeric page parameter
54
+ const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
757
55
 
758
56
  return (
759
57
  <div>
@@ -761,13 +59,19 @@ export function ClientProductFeed() {
761
59
  type="text"
762
60
  value={search}
763
61
  onChange={(e) => {
764
- setPage(1);
62
+ setPage(1); // Reset page index on search
765
63
  setSearch(e.target.value);
766
64
  }}
767
- placeholder="Filter products..."
65
+ placeholder="Filter results..."
768
66
  />
769
67
 
770
- {isLoading ? <p>Loading...</p> : <div>{/* Render items */}</div>}
68
+ <button disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
69
+ Prev
70
+ </button>
71
+ <span>Page {page}</span>
72
+ <button onClick={() => setPage((p) => p + 1)}>
73
+ Next
74
+ </button>
771
75
  </div>
772
76
  );
773
77
  }
@@ -775,593 +79,75 @@ export function ClientProductFeed() {
775
79
 
776
80
  ---
777
81
 
778
- ### 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.
82
+ ## API Summary
860
83
 
861
- ### Complete Dynamic Facet Component
84
+ ### Built-in Parsers
862
85
 
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
86
+ | Parser | Parse Signature | Clean URL Behavior | Example |
87
+ | :--- | :--- | :--- | :--- |
88
+ | `parseAsString` | `string \| null` | Omitted if `""` or default | `?q=laptop` |
89
+ | `parseAsInteger` | `number \| null` | Omitted if default | `?page=2` |
90
+ | `parseAsFloat` | `number \| null` | Omitted if default | `?rating=4.5` |
91
+ | `parseAsBoolean` | `boolean \| null` | Omitted if default | `?inStock=true` |
92
+ | `parseAsArray(parser, sep?)` | `T[]` | Omitted if `[]` or default | `?tags=shoes,sale` |
93
+ | `parseAsNumberRange(min, max, sep?)` | `{ min, max }` | Omitted if matches bounds | `?price=50-250` |
94
+ | `parseAsSort(field, order?, sep?)` | `{ field, order }` | Omitted if matches default | `?sort=price.asc` |
95
+ | `createParser(config)` | `T` | Custom serializer | User-defined |
939
96
 
940
- 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"*.
97
+ > Calling `.withDefault(fallback)` on any parser narrows the returned state type from `T | null` to non-nullable `T`.
945
98
 
946
- ### Auto-Reset Solution Patterns
99
+ ### Hooks
947
100
 
948
- #### Solution A: Single Hook (`useQueryState`)
949
- Reset page to `1` when triggering filter updates:
101
+ #### `useQueryState(key, parser, options?)`
102
+ Manages an individual URL parameter with optimistic local state.
950
103
 
951
104
  ```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
- };
105
+ const [value, setValue, reset, isPending] = useQueryState('q', parseAsString.withDefault(''), {
106
+ debounceMs: 300, // Debounce URL commit (0ms local state feedback)
107
+ history: 'replace', // 'replace' | 'push'
108
+ scroll: false, // Scroll to top on update
109
+ });
959
110
  ```
960
111
 
961
- #### 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:
112
+ #### `useBatchQueryParams(options?)`
113
+ Executes multiple parameter changes in a single URL transition (for modals or drawers):
963
114
 
964
115
  ```tsx
965
- const { setBatchParams } = useBatchQueryParams();
116
+ const { setBatchParams, resetAllParams, isPending } = useBatchQueryParams();
966
117
 
118
+ // Commit multiple filter updates in a single transition
967
119
  setBatchParams({
968
- brand: 'Sony',
969
- price: '100-300',
970
- page: null, // Deletes 'page' parameter from URL
120
+ q: 'shoes',
121
+ categories: { value: ['running'], parser: parseAsArray(parseAsString) },
122
+ price: { value: { min: 50, max: 200 }, parser: parseAsNumberRange(0, 1000) },
123
+ page: null, // Deletes 'page' from URL (resets to page 1)
971
124
  });
972
- ```
973
-
974
- ---
975
-
976
- ## 11. Complete Production Recipes
977
-
978
- ### Recipe 1: Instant Reactive E-Commerce Filter Bar
979
-
980
- ```tsx
981
- 'use client';
982
-
983
- import React from 'react';
984
- import {
985
- useQueryState,
986
- parseAsString,
987
- parseAsInteger,
988
- parseAsBoolean,
989
- parseAsArray,
990
- parseAsSort,
991
- } from 'paramsync';
992
-
993
- type SortField = 'price' | 'rating' | 'createdAt';
994
- const CATEGORIES = ['Footwear', 'Apparel', 'Accessories', 'Electronics'];
995
-
996
- export function InstantFilterBar() {
997
- // Live Search with 300ms debounce
998
- const [search, setSearch, resetSearch, isSearchPending] = useQueryState(
999
- 'q',
1000
- parseAsString.withDefault(''),
1001
- { debounceMs: 300 }
1002
- );
1003
125
 
1004
- // 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
- }
126
+ // Wipe all non-essential parameters at once
127
+ resetAllParams(['tab', 'utm_source']);
1099
128
  ```
1100
129
 
1101
130
  ---
1102
131
 
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
- };
132
+ ## Clean URL Policy
1153
133
 
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
- ```
134
+ `paramsync` automatically deletes a parameter from the query string when:
135
+ 1. Its value equals `defaultValue`.
136
+ 2. Its value is an empty string `""`.
137
+ 3. Its value is an empty array `[]`.
138
+ 4. Its value is `null` or `undefined`.
1213
139
 
1214
140
  ---
1215
141
 
1216
- ### Recipe 3: Data Table with Sorting & Dynamic Page Size
1217
-
1218
- ```tsx
1219
- 'use client';
142
+ ## Documentation & Repository
1220
143
 
1221
- import React from 'react';
1222
- import { useQueryState, parseAsSort, parseAsInteger } from 'paramsync';
144
+ For complete architectural guides, Server Components (RSC) data fetching patterns, TanStack Query integration, dynamic facet filtering, and recipes:
1223
145
 
1224
- 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
- ```
146
+ - **Repository & Full Docs:** [https://github.com/3b3zeem/paramsync](https://github.com/3b3zeem/paramsync)
147
+ - **Issues & Feedback:** [https://github.com/3b3zeem/paramsync/issues](https://github.com/3b3zeem/paramsync/issues)
1362
148
 
1363
149
  ---
1364
150
 
1365
- ## 15. License
151
+ ## License
1366
152
 
1367
- MIT © [paramsync](https://github.com/paramsync/paramsync)
153
+ MIT © [Ahmed Mostafa](https://github.com/3b3zeem/paramsync)