vue-context-storage 0.1.40 → 0.1.42

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,910 +1,963 @@
1
- # vue-context-storage
2
-
3
- Vue 3 context storage system with URL query, localStorage, and sessionStorage synchronization support.
4
-
5
- [![npm downloads](https://img.shields.io/npm/dm/vue-context-storage.svg)](https://www.npmjs.com/package/vue-context-storage)
6
- [![TypeScript](https://badgen.net/badge/icon/TypeScript?icon=typescript&label)](https://www.typescriptlang.org/)
7
- [![Vue 3](https://img.shields.io/badge/vue-3.x-brightgreen.svg)](https://vuejs.org/)
8
- [![Bundle Size](https://img.shields.io/bundlephobia/minzip/vue-context-storage)](https://bundlephobia.com/package/vue-context-storage)
9
- [![GitHub issues](https://img.shields.io/github/issues/lviobio/vue-context-storage)](https://github.com/lviobio/vue-context-storage/issues)
10
- [![GitHub License](https://img.shields.io/github/license/lviobio/vue-context-storage)](https://github.com/lviobio/vue-context-storage)
11
- ![CI](https://github.com/lviobio/vue-context-storage/actions/workflows/ci.yml/badge.svg)
12
- ![Coverage](https://github.com/lviobio/vue-context-storage/actions/workflows/coverage.yml/badge.svg)
13
- [![codecov](https://codecov.io/gh/lviobio/vue-context-storage/branch/main/graph/badge.svg)](https://codecov.io/gh/lviobio/vue-context-storage)
14
- [![Live Demo](https://img.shields.io/badge/demo-live-brightgreen)](https://lviobio.github.io/vue-context-storage/)
15
-
16
- A powerful state management solution for Vue 3 applications that provides:
17
-
18
- - **Context-based storage** using Vue's provide/inject API
19
- - **Automatic URL query synchronization** for preserving state across page reloads
20
- - **localStorage & sessionStorage handlers** for persistent and session-scoped state
21
- - **Multiple storage contexts** with activation management
22
- - **Type-safe** TypeScript support
23
- - **Tree-shakeable** and lightweight
24
-
25
- ## Live Demo
26
-
27
- 🚀 **[Try the interactive playground](https://lviobio.github.io/vue-context-storage)**
28
-
29
- ## Installation
30
-
31
- ```bash
32
- npm install vue-context-storage
33
- ```
34
-
35
- ## Features
36
-
37
- - **Vue 3 Composition API** - Built with modern Vue patterns
38
- - ✅ **URL Query Sync** - Automatically sync state with URL parameters
39
- - **localStorage Handler** - Persist state to localStorage with cross-tab sync
40
- - ✅ **sessionStorage Handler** - Session-scoped state that survives page refreshes
41
- - ✅ **Multiple Contexts** - Support multiple independent storage contexts
42
- - ✅ **TypeScript** - Full type safety and IntelliSense support
43
- - ✅ **Flexible** - Works with vue-router 4+ or 5+
44
- - **Transform Helpers** - Built-in utilities for type conversion
45
-
46
- ## Motivation
47
-
48
- In Vue applications, reactive state often needs to live beyond a single component. Filters, pagination, sorting, and user preferences must survive page reloads, be shareable via URL, or persist across sessions. Solving this typically means writing the same boilerplate over and over: manually reading and writing query parameters with vue-router, serializing objects to localStorage, handling type coercion from URL strings, and keeping everything in sync.
49
-
50
- `vue-context-storage` eliminates that repetitive work. You declare your reactive state once, point it at a storage target, and the library handles the rest:
51
-
52
- - **URL query parameters** stay in sync with your data automatically - users can bookmark or share a page and get the exact same state back.
53
- - **localStorage and sessionStorage** are kept up to date without manual `getItem`/`setItem` calls, including cross-tab synchronization.
54
- - **Type safety** is preserved end-to-end: URL strings are coerced back to numbers, booleans, and arrays via transform helpers or Zod schemas.
55
- - **Multiple independent contexts** (e.g. two data tables on the same page) are supported out of the box through the key pattern, so query parameters never collide.
56
-
57
- The goal is a single, declarative API - `useContextStorage('query', data, options)` - that replaces scattered watchers, router guards, and storage listeners with one composable call per piece of state.
58
-
59
- ## Basic Usage
60
-
61
- ### Option 1: Manual Component Import (Recommended)
62
-
63
- Import ContextStorage component in your `App.vue`:
64
-
65
- ```vue
66
- <template>
67
- <ContextStorage>
68
- <router-view />
69
- </ContextStorage>
70
- </template>
71
-
72
- <script setup lang="ts">
73
- import { ContextStorage } from 'vue-context-storage'
74
- </script>
75
- ```
76
-
77
- ### Option 2: Using Vue Plugin
78
-
79
- Register the plugin in your main app file:
80
-
81
- ```typescript
82
- import { createApp } from 'vue'
83
- import { VueContextStoragePlugin } from 'vue-context-storage'
84
- import App from './App.vue'
85
-
86
- const app = createApp(App)
87
-
88
- // Register components globally
89
- app.use(VueContextStoragePlugin)
90
-
91
- app.mount('#app')
92
- ```
93
-
94
- Then use components without importing in your `App.vue`:
95
-
96
- ```vue
97
- <template>
98
- <ContextStorage>
99
- <router-view />
100
- </ContextStorage>
101
- </template>
102
- ```
103
-
104
- ## Unified Composable
105
-
106
- `useContextStorage()` provides a single entry point for all handler types:
107
-
108
- ```vue
109
- <script setup lang="ts">
110
- import { reactive } from 'vue'
111
- import { useContextStorage } from 'vue-context-storage'
112
-
113
- const filters = reactive({
114
- search: '',
115
- status: 'active',
116
- page: 1,
117
- })
118
-
119
- // Sync with URL query
120
- useContextStorage('query', filters, {
121
- key: 'filters',
122
- })
123
-
124
- // Sync with localStorage
125
- useContextStorage('localStorage', filters, {
126
- key: 'saved-filters',
127
- })
128
-
129
- // Sync with sessionStorage
130
- useContextStorage('sessionStorage', filters, {
131
- key: 'temp-filters',
132
- })
133
- </script>
134
- ```
135
-
136
- **Important: Query handler type coercion.** URL query parameters are always strings. When state is restored from the URL, non-string values lose their original types — `{ page: 1 }` becomes `{ page: "1" }`, booleans become `"true"` / `"false"`, and arrays are restored as plain objects. Always use `schema` or `transform` option when using the query handler with non-string values:
137
-
138
- ```typescript
139
- // Option 1: Zod schema (recommended)
140
- useContextStorage('query', filters, {
141
- key: 'filters',
142
- schema: z.object({
143
- page: z.coerce.number().default(1),
144
- search: z.string().default(''),
145
- status: z.string().default('active'),
146
- }),
147
- })
148
-
149
- // Option 2: Transform function
150
- useContextStorage('query', filters, {
151
- key: 'filters',
152
- transform: (deserialized, initial) => ({
153
- page: asNumber(deserialized.page, { fallback: initial.page }),
154
- search: asString(deserialized.search, { fallback: initial.search }),
155
- status: asString(deserialized.status, { fallback: initial.status }),
156
- }),
157
- })
158
- ```
159
-
160
- Without type coercion, comparisons like `page === 1` will silently fail after URL restore (the actual value will be `"1"`). The library emits a runtime `console.warn` when it detects non-string values registered without `schema` or `transform`.
161
-
162
- Options are type-checked per handler — `'query'` accepts query options, `'localStorage'` and `'sessionStorage'` require a `key`, etc.
163
-
164
- You can also pass an injection key directly instead of a string:
165
-
166
- ```typescript
167
- import { contextStorageQueryHandlerInjectKey } from 'vue-context-storage'
168
-
169
- useContextStorage(contextStorageQueryHandlerInjectKey, filters, {
170
- key: 'filters',
171
- })
172
- ```
173
-
174
- ## Use Query Handler in Components
175
-
176
- Sync reactive state with URL query parameters:
177
-
178
- ```vue
179
- <script setup lang="ts">
180
- import { reactive } from 'vue'
181
- import { useContextStorage } from 'vue-context-storage'
182
-
183
- interface Filters {
184
- search: string
185
- status: string
186
- page: number
187
- }
188
-
189
- const filters = reactive<Filters>({
190
- search: '',
191
- status: 'active',
192
- page: 1,
193
- })
194
-
195
- // Automatically syncs filters with URL query
196
- useContextStorage('query', filters, {
197
- key: 'filters', // URL will be: ?filters[search]=...&filters[status]=...
198
- })
199
- </script>
200
- ```
201
-
202
- ## Advanced Usage
203
-
204
- ### Using Transform Helpers
205
-
206
- Convert URL query string values to proper types:
207
-
208
- ```typescript
209
- import { ref } from 'vue'
210
- import { useContextStorage, transform } from 'vue-context-storage'
211
-
212
- interface TableState {
213
- page: number
214
- search: string
215
- perPage: number
216
- }
217
-
218
- const state = ref<TableState>({
219
- page: 1,
220
- search: '',
221
- perPage: 25,
222
- })
223
-
224
- useContextStorage('query', state, {
225
- key: 'table',
226
- transform: (deserialized, initial) => ({
227
- page: transform.asNumber(deserialized.page, { fallback: 1 }),
228
- search: transform.asString(deserialized.search, { fallback: '' }),
229
- perPage: transform.asNumber(deserialized.perPage, { fallback: 25 }),
230
- }),
231
- })
232
- ```
233
-
234
- ### Available Transform Helpers
235
-
236
- - `asNumber(value, options)` - Convert to number
237
- - `asString(value, options)` - Convert to string
238
- - `asBoolean(value, options)` - Convert to boolean
239
- - `asArray(value, options)` - Convert to array
240
- - `asNumberArray(value, options)` - Convert to number array
241
- - `asObjectArray(value, options)` - Convert indexed object to array of objects (see [Arrays of Objects](#arrays-of-objects))
242
-
243
- ### Using Zod Schemas
244
-
245
- Alternatively, you can use [Zod](https://zod.dev/) schemas for automatic validation and type inference:
246
-
247
- ```typescript
248
- import { z } from 'zod'
249
- import { useContextStorage } from 'vue-context-storage'
250
-
251
- // Define schema with automatic coercion
252
- const FiltersSchema = z.object({
253
- search: z.string().default(''),
254
- page: z.coerce.number().int().positive().default(1),
255
- status: z.enum(['active', 'inactive']).default('active'),
256
- })
257
-
258
- const filters = ref(FiltersSchema.parse({}))
259
-
260
- // Use schema for automatic validation
261
- useContextStorage('query', filters, {
262
- key: 'filters',
263
- schema: FiltersSchema,
264
- })
265
- ```
266
-
267
- **Benefits:**
268
-
269
- - Automatic type coercion (strings → numbers, etc.)
270
- - Runtime validation with detailed errors
271
- - Automatic TypeScript type inference
272
- - Less boilerplate code
273
- - Single source of truth for structure and validation
274
-
275
- ### Arrays of Objects
276
-
277
- The query handler supports arrays of objects. They are serialized as indexed query parameters:
278
-
279
- ```
280
- ?items[0][product]=Apple&items[0][quantity]=5&items[1][product]=Banana&items[1][quantity]=10
281
- ```
282
-
283
- After deserialization, URL parameters produce indexed objects (`{ '0': {...}, '1': {...} }`) rather than arrays. Use `transform.asObjectArray` or the Zod helper `zObjectArray` to convert them back.
284
-
285
- **With transform helpers:**
286
-
287
- ```typescript
288
- import { reactive } from 'vue'
289
- import { useContextStorage, transform } from 'vue-context-storage'
290
-
291
- const data = reactive({
292
- title: '',
293
- items: [] as { product: string; quantity: number }[],
294
- })
295
-
296
- useContextStorage('query', data, {
297
- transform: (value) => ({
298
- title: transform.asString(value.title),
299
- items: transform.asObjectArray(value.items, (entry) => ({
300
- product: transform.asString(entry.product),
301
- quantity: transform.asNumber(entry.quantity),
302
- })),
303
- }),
304
- })
305
- ```
306
-
307
- `asObjectArray` also supports a callback shorthand — pass a function as the second argument instead of an options object.
308
-
309
- **With Zod schema:**
310
-
311
- ```typescript
312
- import { z } from 'zod'
313
- import { zObjectArray } from 'vue-context-storage/zod'
314
-
315
- const ItemSchema = z.object({
316
- product: z.string().default(''),
317
- quantity: z.coerce.number().default(0),
318
- })
319
-
320
- const DataSchema = z.object({
321
- title: z.string().default(''),
322
- items: zObjectArray(ItemSchema),
323
- })
324
-
325
- useContextStorage('query', data, { schema: DataSchema })
326
- ```
327
-
328
- See [Zod Helpers](#zod-helpers-vue-context-storagezod) for more details.
329
-
330
- ### Preserve Empty State
331
-
332
- Keep empty state in URL to prevent resetting on reload:
333
-
334
- ```typescript
335
- useContextStorage('query', filters, {
336
- key: 'filters',
337
- preserveEmptyState: true,
338
- // Empty filters will show as: ?filters
339
- // Without this option, empty filters would clear the URL completely
340
- })
341
- ```
342
-
343
- ### Additional Default Data
344
-
345
- When `onlyChanges` is enabled (the default), a key is omitted from the URL if its current value matches the initial snapshot. `additionalDefaultData` lets you specify extra values that should also be treated as defaults and excluded from the URL.
346
-
347
- This is useful when the initial reactive data starts with `undefined` (e.g. before an API response), but you also want a specific value (like `1`) to be considered a default:
348
-
349
- ```typescript
350
- const data = ref({ page: undefined as number | undefined })
351
-
352
- useContextStorage('query', data, {
353
- key: 'filters',
354
- onlyChanges: true,
355
- additionalDefaultData: { page: 1 },
356
- })
357
-
358
- // page=undefined → not in query (matches initial)
359
- // page=1 → not in query (matches additionalDefaultData)
360
- // page=2 → appears in query as ?filters[page]=2
361
- ```
362
-
363
- ### Configure Query Handler
364
-
365
- Customize behavior by passing options to the factory:
366
-
367
- ```vue
368
- <template>
369
- <!-- Override a single default handler without listing all of them -->
370
- <ContextStorage :additional-handlers="[createQueryHandler({ mode: 'push' })]">
371
- <RouterView />
372
- </ContextStorage>
373
- </template>
374
- ```
375
-
376
- The `additional-handlers` prop merges with the default handlers, replacing any handler of the same type (matched by injection key). This is the recommended way to customize a single handler.
377
-
378
- If you need full control over all handlers, use the `handlers` prop instead:
379
-
380
- ```typescript
381
- import {
382
- createQueryHandler,
383
- createLocalStorageHandler,
384
- createSessionStorageHandler,
385
- } from 'vue-context-storage'
386
-
387
- const customHandlers = [
388
- createQueryHandler({
389
- mode: 'push', // 'replace' (default) or 'push' for history
390
- preserveUnusedKeys: false, // Default is true — set to false for exclusive query ownership
391
- preserveEmptyState: false,
392
- }),
393
- createLocalStorageHandler(),
394
- createSessionStorageHandler(),
395
- ]
396
-
397
- // Pass to ContextStorage or ContextStorageCollection component:
398
- // <ContextStorage :handlers="customHandlers">
399
- ```
400
-
401
- ## Use localStorage Handler in Components
402
-
403
- Persist reactive state to `localStorage`. Data is automatically synced across browser tabs.
404
-
405
- ```vue
406
- <script setup lang="ts">
407
- import { reactive } from 'vue'
408
- import { useContextStorage } from 'vue-context-storage'
409
-
410
- const settings = reactive({
411
- theme: 'light',
412
- fontSize: 14,
413
- sidebarOpen: true,
414
- })
415
-
416
- // Automatically syncs settings with localStorage under the key "app-settings"
417
- useContextStorage('localStorage', settings, {
418
- key: 'app-settings',
419
- })
420
- </script>
421
- ```
422
-
423
- ### Configure localStorage Handler
424
-
425
- ```typescript
426
- import { createLocalStorageHandler } from 'vue-context-storage'
427
-
428
- const customLocalStorage = createLocalStorageHandler({
429
- listenToStorageEvents: true, // Cross-tab sync (default: true)
430
- })
431
- ```
432
-
433
- ## Use sessionStorage Handler in Components
434
-
435
- Persist reactive state to `sessionStorage`. Data survives page refreshes but is cleared when the tab is closed.
436
-
437
- ```vue
438
- <script setup lang="ts">
439
- import { reactive } from 'vue'
440
- import { useContextStorage } from 'vue-context-storage'
441
-
442
- const formDraft = reactive({
443
- email: '',
444
- message: '',
445
- step: 1,
446
- })
447
-
448
- // Automatically syncs form draft with sessionStorage
449
- useContextStorage('sessionStorage', formDraft, {
450
- key: 'contact-form-draft',
451
- })
452
- </script>
453
- ```
454
-
455
- ### Multiple Registrations Under One Root Key
456
-
457
- Use bracket notation in `key` to store multiple data objects under a common root:
458
-
459
- ```typescript
460
- const filters = reactive({ search: '', status: 'active' })
461
-
462
- useContextStorage('sessionStorage', filters, {
463
- key: 'app-state[filters]', // Storage key: 'app-state[filters]'
464
- })
465
-
466
- const pagination = reactive({ page: 1, perPage: 25 })
467
-
468
- useContextStorage('sessionStorage', pagination, {
469
- key: 'app-state[pagination]', // Storage key: 'app-state[pagination]'
470
- })
471
- ```
472
-
473
- Or use `<ContextStoragePrefix>` for automatic scoping (see [Prefix Scoping](#prefix-scoping-with-contextstorageprefix)).
474
-
475
- ### Using Transform with Storage Handlers
476
-
477
- Convert stored values to proper types when reading from storage:
478
-
479
- ```typescript
480
- import { useContextStorage, transform } from 'vue-context-storage'
481
-
482
- const settings = reactive({
483
- theme: 'light',
484
- fontSize: 14,
485
- })
486
-
487
- useContextStorage('localStorage', settings, {
488
- key: 'app-settings',
489
- transform: (deserialized, initial) => ({
490
- theme: transform.asString(deserialized.theme, { fallback: 'light' }),
491
- fontSize: transform.asNumber(deserialized.fontSize, { fallback: 14 }),
492
- }),
493
- })
494
- ```
495
-
496
- ### Using Zod Schemas with Storage Handlers
497
-
498
- ```typescript
499
- import { z } from 'zod'
500
- import { useContextStorage } from 'vue-context-storage'
501
-
502
- const SettingsSchema = z.object({
503
- theme: z.enum(['light', 'dark']).default('light'),
504
- fontSize: z.number().int().positive().default(14),
505
- sidebarOpen: z.boolean().default(true),
506
- })
507
-
508
- const settings = reactive(SettingsSchema.parse({}))
509
-
510
- useContextStorage('localStorage', settings, {
511
- key: 'app-settings',
512
- schema: SettingsSchema,
513
- })
514
- ```
515
-
516
- ### Custom Serialization
517
-
518
- Provide custom serializer/deserializer functions:
519
-
520
- ```typescript
521
- useContextStorage('localStorage', settings, {
522
- key: 'app-settings',
523
- serializer: (data) => btoa(JSON.stringify(data)),
524
- deserializer: (str) => JSON.parse(atob(str)),
525
- })
526
- ```
527
-
528
- ## Prefix Scoping with `<ContextStoragePrefix>`
529
-
530
- The `<ContextStoragePrefix>` component adds a prefix to all `useContextStorage` calls within its subtree. Prefixes stack when nested, and are concatenated with bracket notation.
531
-
532
- ### Basic Usage
533
-
534
- ```vue
535
- <template>
536
- <ContextStoragePrefix name="table">
537
- <MyTable />
538
- </ContextStoragePrefix>
539
- </template>
540
- ```
541
-
542
- Inside `MyTable`, any `useContextStorage('query', data)` call will automatically get `key: 'tables'`. If the composable also specifies its own key, they are combined:
543
-
544
- ```typescript
545
- // Inside MyTable — effective key becomes 'table[filters]'
546
- useContextStorage('query', filters, { key: 'filters' })
547
- // URL: ?table[filters][search]=...
548
- ```
549
-
550
- ### Stacking Prefixes
551
-
552
- Nested `<ContextStoragePrefix>` components stack their prefixes:
553
-
554
- ```vue
555
- <ContextStoragePrefix name="tables">
556
- <ContextStoragePrefix name="first">
557
- <!-- All handlers here get prefix 'tables[first]' -->
558
- <!-- useContextStorage('query', data) → URL: ?tables[first][search]=... -->
559
- <!-- useContextStorage('localStorage', data, { key: 'state' }) → key: 'state[tables][first]' -->
560
- </ContextStoragePrefix>
561
- </ContextStoragePrefix>
562
- ```
563
-
564
- ### Per-Handler Prefixes
565
-
566
- Pass an object to apply different prefixes per handler type:
567
-
568
- ```vue
569
- <ContextStoragePrefix :name="{ query: 'url-tables', localStorage: 'ls-data' }">
570
- <!-- query handler gets prefix 'url-tables' -->
571
- <!-- localStorage handler gets prefix 'ls-data' -->
572
- <!-- sessionStorage handler gets no prefix (not specified) -->
573
- </ContextStoragePrefix>
574
- ```
575
-
576
- ### Dynamic Prefix
577
-
578
- When the `name` prop changes, all descendant components are re-created and re-registered with the new prefix:
579
-
580
- ```vue
581
- <ContextStoragePrefix :name="activeTab">
582
- <TabContent />
583
- </ContextStoragePrefix>
584
- ```
585
-
586
- ## Registering Custom Handlers
587
-
588
- Register your own handlers at runtime and extend the type map for full type safety:
589
-
590
- ```typescript
591
- import { defineContextStorageHandler } from 'vue-context-storage'
592
- import { myHandlerInjectionKey } from './my-handler'
593
-
594
- // Runtime registration
595
- defineContextStorageHandler('myHandler', myHandlerInjectionKey)
596
-
597
- // TypeScript augmentation (e.g. in a .d.ts or at module level)
598
- declare module 'vue-context-storage' {
599
- interface ContextStorageHandlerMap {
600
- myHandler: { key: string }
601
- }
602
- }
603
-
604
- // Now fully type-checked
605
- useContextStorage('myHandler', data, { key: 'example' })
606
- ```
607
-
608
- ## API Reference
609
-
610
- ### Composables
611
-
612
- #### `useContextStorage(type, data, options)`
613
-
614
- Unified composable that delegates to the correct handler based on `type`.
615
-
616
- **Parameters:**
617
-
618
- - `type: 'query' | 'localStorage' | 'sessionStorage' | InjectionKey` - Handler type or injection key
619
- - `data: MaybeRefOrGetter<T>` - Reactive reference to sync
620
- - `options` - Handler-specific options (type-checked per handler)
621
-
622
- **Returns:** `{ data, stop, reset, wasChanged }`
623
-
624
- - `data` - The reactive reference passed in
625
- - `stop()` - Unregister and stop syncing (called automatically on unmount)
626
- - `reset()` - Restore data to its initial state
627
- - `wasChanged: ComputedRef<boolean>` - Whether data differs from initial state
628
-
629
- **Custom handler registration:**
630
-
631
- - `defineContextStorageHandler(name, injectionKey)` - Register a custom handler
632
- - `resolveHandlerInjectionKey(type)` - Look up an injection key by name
633
-
634
- ### Handler Factories
635
-
636
- #### `createQueryHandler(options?)`
637
-
638
- Creates a query handler factory for URL query synchronization.
639
-
640
- **Options:**
641
-
642
- - `mode?: 'replace' | 'push'` - Router navigation mode (default: `'replace'`)
643
- - `preserveUnusedKeys?: boolean` - Keep other query params (default: `true`)
644
- - `preserveEmptyState?: boolean` - Preserve empty state in URL (default: `false`)
645
- - `emptyPlaceholder?: string` - Placeholder for empty state (default: `'_'`)
646
- - `onlyChanges?: boolean` - Only write changed values to URL (default: `true`)
647
-
648
- #### `createLocalStorageHandler(options?)`
649
-
650
- Creates a localStorage handler factory.
651
-
652
- **Options:**
653
-
654
- - `listenToStorageEvents?: boolean` - Enable cross-tab sync (default: `true`)
655
-
656
- #### `createSessionStorageHandler(options?)`
657
-
658
- Creates a sessionStorage handler factory.
659
-
660
- **Options:**
661
-
662
- - `listenToStorageEvents?: boolean` - Listen to storage events (default: `false`)
663
-
664
- ### Components
665
-
666
- #### `<ContextStoragePrefix>`
667
-
668
- Scopes a prefix for all descendant `useContextStorage` calls via provide/inject.
669
-
670
- **Props:**
671
-
672
- - `name: string | Partial<Record<string, string>>` (required) - Prefix to apply. A string applies to all handlers; an object applies per handler type (e.g. `{ query: 'q', localStorage: 'ls' }`)
673
-
674
- Nested `<ContextStoragePrefix>` components stack their prefixes using bracket notation. When `name` changes dynamically, all descendant components are re-created.
675
-
676
- ### Transform Helpers
677
-
678
- All transform helpers support nullable and missable options:
679
-
680
- ```typescript
681
- transform.asNumber(value, {
682
- fallback: 0, // Default value
683
- nullable: false, // Allow null return
684
- missable: false, // Allow undefined return
685
- })
686
- ```
687
-
688
- ## Zod Helpers (`vue-context-storage/zod`)
689
-
690
- The library provides a separate entry point with Zod-specific helpers. Since `zod` is an optional peer dependency, these helpers are isolated in `vue-context-storage/zod` to avoid importing Zod in the main bundle.
691
-
692
- ```bash
693
- npm install zod
694
- ```
695
-
696
- ### `zObjectArray(itemSchema)`
697
-
698
- Creates a Zod schema for arrays of objects serialized as indexed query parameters. Wraps `z.record()` + `.transform()` to convert indexed objects back to sorted arrays.
699
-
700
- ```typescript
701
- import { z } from 'zod'
702
- import { zObjectArray } from 'vue-context-storage/zod'
703
-
704
- const ItemSchema = z.object({
705
- product: z.string().default(''),
706
- quantity: z.coerce.number().default(0),
707
- })
708
-
709
- const DataSchema = z.object({
710
- title: z.string().default(''),
711
- items: zObjectArray(ItemSchema),
712
- })
713
- ```
714
-
715
- ### `zBoolean(defaultValue?)`
716
-
717
- Creates a Zod schema for booleans serialized as URL query parameters. Standard `z.coerce.boolean()` cannot be used because `Boolean('0')` is `true` in JavaScript. This helper correctly handles `'1'`, `'true'`, `'0'`, `'false'`, and native booleans.
718
-
719
- ```typescript
720
- import { z } from 'zod'
721
- import { zBoolean } from 'vue-context-storage/zod'
722
-
723
- const Schema = z.object({
724
- active: zBoolean(), // defaults to false
725
- enabled: zBoolean(true), // defaults to true
726
- })
727
- ```
728
-
729
- ### `zNumberArray()`
730
-
731
- Creates a Zod schema for arrays of numbers serialized as URL query parameters. Handles the common issue where a single query value (`?ids=1`) is deserialized as a string `'1'` instead of an array `['1']`, which would cause `z.coerce.number().array()` to fail with `"expected array, received string"`.
732
-
733
- This helper normalizes the input by accepting both a single value and an array, coercing each element to a number.
734
-
735
- ```typescript
736
- import { z } from 'zod'
737
- import { zNumberArray } from 'vue-context-storage/zod'
738
-
739
- const Schema = z.object({
740
- users_ids: zNumberArray(),
741
- })
742
-
743
- // All of these work:
744
- Schema.parse({ users_ids: ['1', '2'] }) // → { users_ids: [1, 2] }
745
- Schema.parse({ users_ids: '1' }) // { users_ids: [1] }
746
- Schema.parse({}) // { users_ids: [] }
747
- ```
748
-
749
- ### `zStringArray()`
750
-
751
- Creates a Zod schema for arrays of strings serialized as URL query parameters. Same problem as `zNumberArray()` — a single query value (`?tags=vue`) is deserialized as a string `'vue'` instead of an array `['vue']`, which would cause `z.string().array()` to fail.
752
-
753
- ```typescript
754
- import { z } from 'zod'
755
- import { zStringArray } from 'vue-context-storage/zod'
756
-
757
- const Schema = z.object({
758
- tags: zStringArray(),
759
- })
760
-
761
- // All of these work:
762
- Schema.parse({ tags: ['vue', 'react'] }) // → { tags: ['vue', 'react'] }
763
- Schema.parse({ tags: 'vue' }) // → { tags: ['vue'] }
764
- Schema.parse({}) // { tags: [] }
765
- ```
766
-
767
- ### `createSchemaObject(schema, options?)`
768
-
769
- Creates a plain object with empty/default values based on a Zod schema. Useful for initializing reactive data from a schema definition.
770
-
771
- ```typescript
772
- import { z } from 'zod'
773
- import { createSchemaObject } from 'vue-context-storage/zod'
774
-
775
- const FiltersSchema = z.object({
776
- search: z.string().default(''),
777
- page: z.coerce.number().default(1),
778
- active: z.boolean().default(false),
779
- score: z.number().nullable(),
780
- })
781
-
782
- const filters = reactive(createSchemaObject(FiltersSchema))
783
- // Result: { search: '', page: 1, active: false, score: null }
784
- ```
785
-
786
- **Options:**
787
-
788
- - `useDefaults` (default: `true`) — When `true`, uses `.default()` values from the schema. When `false`, uses type-based empty values (`''` for strings, `0` for numbers, `false` for booleans, etc.).
789
- - `withSchema` (default: `false`) — When `true`, attaches the schema to the result object via `SCHEMA_SYMBOL` (wrapped with `markRaw`). Nested objects also receive their respective schemas.
790
-
791
- ```typescript
792
- import { createSchemaObject, SCHEMA_SYMBOL } from 'vue-context-storage/zod'
793
-
794
- const data = createSchemaObject(FiltersSchema, { withSchema: true })
795
- data[SCHEMA_SYMBOL] // → FiltersSchema
796
- ```
797
-
798
- **Type-based defaults** (when `useDefaults: false` or no `.default()` is set):
799
-
800
- | Zod type | Default value |
801
- | ------------- | -------------------------------------------- |
802
- | `z.string()` | `''` |
803
- | `z.number()` | `0` (respects `.min()` / `.positive()`) |
804
- | `z.boolean()` | `false` |
805
- | `z.array()` | `[]` |
806
- | `z.object()` | Recursively created via `createSchemaObject` |
807
- | `z.date()` | `null` |
808
- | `.nullable()` | `null` |
809
- | `.optional()` | `undefined` |
810
-
811
- ## TypeScript Support
812
-
813
- Full TypeScript support with type inference:
814
-
815
- ```typescript
816
- import type {
817
- ContextStorageHandler,
818
- ContextStorageHandlerFactory,
819
- QueryValue,
820
- } from 'vue-context-storage'
821
- ```
822
-
823
- When using Zod schemas, TypeScript will automatically infer types:
824
-
825
- ```typescript
826
- const FiltersSchema = z.object({
827
- search: z.string().default(''),
828
- page: z.coerce.number().default(1),
829
- })
830
-
831
- type Filters = z.infer<typeof FiltersSchema>
832
- // Result: { search: string; page: number }
833
- ```
834
-
835
- ## Examples
836
-
837
- ### Pagination with URL Sync
838
-
839
- ```typescript
840
- import { ref } from 'vue'
841
- import { useContextStorage, transform } from 'vue-context-storage'
842
-
843
- const pagination = ref({
844
- page: 1,
845
- perPage: 25,
846
- total: 0,
847
- })
848
-
849
- useContextStorage('query', pagination, {
850
- key: 'page',
851
- transform: (data, initial) => ({
852
- page: transform.asNumber(data.page, { fallback: 1 }),
853
- perPage: transform.asNumber(data.perPage, { fallback: 25 }),
854
- total: initial.total, // Don't sync total from URL
855
- }),
856
- })
857
- ```
858
-
859
- ## Peer Dependencies
860
-
861
- - `vue`: ^3.0.0
862
- - `vue-router`: ^4.0.0 || ^5.0.0
863
- - `zod`: ^4.0.0 (optional - only if using schema validation)
864
-
865
- ## License
866
-
867
- MIT
868
-
869
- ## Development
870
-
871
- ### Running Playground Locally
872
-
873
- ```bash
874
- # Development mode (hot reload)
875
- npm run play
876
-
877
- # Production preview
878
- npm run build:playground
879
- npm run preview:playground
880
- ```
881
-
882
- ### Building
883
-
884
- ```bash
885
- # Build library
886
- npm run build
887
-
888
- # Build playground for deployment
889
- npm run build:playground
890
- ```
891
-
892
- ### Testing & Quality
893
-
894
- ```bash
895
- # Run all checks
896
- npm run check
897
-
898
- # Type checking
899
- npm run ts:check
900
-
901
- # Linting
902
- npm run lint
903
-
904
- # Formatting
905
- npm run format
906
- ```
907
-
908
- ## Contributing
909
-
910
- Contributions are welcome! Please feel free to submit a Pull Request.
1
+ # vue-context-storage
2
+
3
+ Vue 3 reactive state management — sync state with the URL query, localStorage, and sessionStorage through a single type-safe composable.
4
+
5
+ [![npm downloads](https://img.shields.io/npm/dm/vue-context-storage.svg)](https://www.npmjs.com/package/vue-context-storage)
6
+ [![TypeScript](https://badgen.net/badge/icon/TypeScript?icon=typescript&label)](https://www.typescriptlang.org/)
7
+ [![Vue 3](https://img.shields.io/badge/vue-3.x-brightgreen.svg)](https://vuejs.org/)
8
+ [![Bundle Size](https://img.shields.io/bundlephobia/minzip/vue-context-storage)](https://bundlephobia.com/package/vue-context-storage)
9
+ [![GitHub issues](https://img.shields.io/github/issues/lviobio/vue-context-storage)](https://github.com/lviobio/vue-context-storage/issues)
10
+ [![GitHub License](https://img.shields.io/github/license/lviobio/vue-context-storage)](https://github.com/lviobio/vue-context-storage)
11
+ ![CI](https://github.com/lviobio/vue-context-storage/actions/workflows/ci.yml/badge.svg)
12
+ ![Coverage](https://github.com/lviobio/vue-context-storage/actions/workflows/coverage.yml/badge.svg)
13
+ [![codecov](https://codecov.io/gh/lviobio/vue-context-storage/branch/main/graph/badge.svg)](https://codecov.io/gh/lviobio/vue-context-storage)
14
+ [![Live Demo](https://img.shields.io/badge/demo-live-brightgreen)](https://lviobio.github.io/vue-context-storage/)
15
+
16
+ Key features:
17
+
18
+ - **Automatic URL query sync** painless type coercion (numbers, booleans, arrays) and nested objects
19
+ - **localStorage & sessionStorage** for persistent and session-scoped state same API as the query handler
20
+ - **Context prefixing** to avoid key collisions when the same key is reused across instances
21
+ - **Type-safe** with optional Zod schema validation
22
+ - **Tree-shakeable** and lightweight
23
+
24
+ ```ts
25
+ const filters = reactive({ search: '', page: 1 })
26
+
27
+ // reactive state URL query — kept in sync automatically, both directions
28
+ useContextStorage('query', filters) // URL: /products?search=shoes&page=2
29
+ useContextStorage('query', filters, { key: 'filters' }) // URL: /products?filters[search]=shoes&filters[page]=2
30
+ // zod schema support with type coercion, 'page' will be converted to number
31
+ useContextStorage('query', filters, { schema: z.object({ search: z.string(), page: z.number() }) }) // URL: /products?filters[search]=shoes&filters[page]=2
32
+ // transform function support with type coercion, 'page' will be converted to number
33
+ useContextStorage('query', filters, { transform: (value) => ({ search: value.search, page: Number(value.page) }) }) // URL: /products?filters[search]=shoes&filters[page]=2
34
+ // And a lot of other features... (onlyChanges option; createEmptyObject helper for zod schemas; additional default values; passing 'key' via ContextStoragePrefix wrapper)
35
+ ```
36
+
37
+ ## Live Demo
38
+
39
+ 🚀 **[Try the interactive playground](https://lviobio.github.io/vue-context-storage)**
40
+
41
+ ## Installation
42
+
43
+ ```bash
44
+ npm install vue-context-storage
45
+ ```
46
+
47
+ ## Features
48
+
49
+ - ✅ **Vue 3 Composition API** - Built with modern Vue patterns
50
+ - **URL Query Sync** - Automatically sync state with URL parameters
51
+ - ✅ **localStorage Handler** - Persist state to localStorage with cross-tab sync
52
+ - **sessionStorage Handler** - Session-scoped state that survives page refreshes
53
+ - **Multiple Contexts** - Support multiple independent storage contexts
54
+ - **TypeScript** - Full type safety and IntelliSense support
55
+ - **Flexible** - Works with vue-router 4+ or 5+
56
+ - ✅ **Transform Helpers** - Built-in utilities for type conversion
57
+
58
+ ## Motivation
59
+
60
+ In Vue applications, reactive state often needs to live beyond a single component. Filters, pagination, sorting, and user preferences must survive page reloads, be shareable via URL, or persist across sessions. Solving this typically means writing the same boilerplate over and over: manually reading and writing query parameters with vue-router, serializing objects to localStorage, handling type coercion from URL strings, and keeping everything in sync.
61
+
62
+ `vue-context-storage` eliminates that repetitive work. You declare your reactive state once, point it at a storage target, and the library handles the rest:
63
+
64
+ - **URL query parameters** stay in sync with your data automatically - users can bookmark or share a page and get the exact same state back.
65
+ - **localStorage and sessionStorage** are kept up to date without manual `getItem`/`setItem` calls, including cross-tab synchronization.
66
+ - **Type safety** is preserved end-to-end: URL strings are coerced back to numbers, booleans, and arrays via transform helpers or Zod schemas.
67
+ - **Multiple independent contexts** (e.g. two data tables on the same page) are supported out of the box through the key pattern, so query parameters never collide.
68
+
69
+ The goal is a single, declarative API - `useContextStorage('query', data, options)` - that replaces scattered watchers, router guards, and storage listeners with one composable call per piece of state.
70
+
71
+ ## Basic Usage
72
+
73
+ ### Option 1: Manual Component Import (Recommended)
74
+
75
+ Import ContextStorage component in your `App.vue`:
76
+
77
+ ```vue
78
+ <template>
79
+ <ContextStorage>
80
+ <router-view />
81
+ </ContextStorage>
82
+ </template>
83
+
84
+ <script setup lang="ts">
85
+ import { ContextStorage } from 'vue-context-storage'
86
+ </script>
87
+ ```
88
+
89
+ ### Option 2: Using Vue Plugin
90
+
91
+ Register the plugin in your main app file:
92
+
93
+ ```typescript
94
+ import { createApp } from 'vue'
95
+ import { VueContextStoragePlugin } from 'vue-context-storage'
96
+ import App from './App.vue'
97
+
98
+ const app = createApp(App)
99
+
100
+ // Register components globally
101
+ app.use(VueContextStoragePlugin)
102
+
103
+ app.mount('#app')
104
+ ```
105
+
106
+ Then use components without importing in your `App.vue`:
107
+
108
+ ```vue
109
+ <template>
110
+ <ContextStorage>
111
+ <router-view />
112
+ </ContextStorage>
113
+ </template>
114
+ ```
115
+
116
+ ## Unified Composable
117
+
118
+ `useContextStorage()` provides a single entry point for all handler types:
119
+
120
+ ```vue
121
+ <script setup lang="ts">
122
+ import { reactive } from 'vue'
123
+ import { useContextStorage } from 'vue-context-storage'
124
+
125
+ const filters = reactive({
126
+ search: '',
127
+ status: 'active',
128
+ page: 1,
129
+ })
130
+
131
+ // Sync with URL query
132
+ useContextStorage('query', filters, {
133
+ key: 'filters',
134
+ })
135
+
136
+ // Sync with localStorage
137
+ useContextStorage('localStorage', filters, {
138
+ key: 'saved-filters',
139
+ })
140
+
141
+ // Sync with sessionStorage
142
+ useContextStorage('sessionStorage', filters, {
143
+ key: 'temp-filters',
144
+ })
145
+ </script>
146
+ ```
147
+
148
+ **Important: Query handler type coercion.** URL query parameters are always strings. When state is restored from the URL, non-string values lose their original types — `{ page: 1 }` becomes `{ page: "1" }`, booleans become `"true"` / `"false"`, and arrays are restored as plain objects. Always use `schema` or `transform` option when using the query handler with non-string values:
149
+
150
+ ```typescript
151
+ // Option 1: Zod schema (recommended)
152
+ useContextStorage('query', filters, {
153
+ key: 'filters',
154
+ schema: z.object({
155
+ page: z.coerce.number().default(1),
156
+ search: z.string().default(''),
157
+ status: z.string().default('active'),
158
+ }),
159
+ })
160
+
161
+ // Option 2: Transform function
162
+ import { transform } from 'vue-context-storage'
163
+
164
+ useContextStorage('query', filters, {
165
+ key: 'filters',
166
+ transform: (deserialized, initial) => ({
167
+ page: transform.asNumber(deserialized.page, { fallback: initial.page }),
168
+ search: transform.asString(deserialized.search, { fallback: initial.search }),
169
+ status: transform.asString(deserialized.status, { fallback: initial.status }),
170
+ }),
171
+ })
172
+ ```
173
+
174
+ Without type coercion, comparisons like `page === 1` will silently fail after URL restore (the actual value will be `"1"`). The library emits a runtime `console.warn` when it detects non-string values registered without `schema` or `transform`.
175
+
176
+ Options are type-checked per handler — `'query'` accepts query options, `'localStorage'` and `'sessionStorage'` require a `key`, etc.
177
+
178
+ You can also pass an injection key directly instead of a string:
179
+
180
+ ```typescript
181
+ import { contextStorageQueryHandlerInjectKey } from 'vue-context-storage'
182
+
183
+ useContextStorage(contextStorageQueryHandlerInjectKey, filters, {
184
+ key: 'filters',
185
+ })
186
+ ```
187
+
188
+ ## Use Query Handler in Components
189
+
190
+ Sync reactive state with URL query parameters:
191
+
192
+ ```vue
193
+ <script setup lang="ts">
194
+ import { reactive } from 'vue'
195
+ import { useContextStorage } from 'vue-context-storage'
196
+
197
+ interface Filters {
198
+ search: string
199
+ status: string
200
+ page: number
201
+ }
202
+
203
+ const filters = reactive<Filters>({
204
+ search: '',
205
+ status: 'active',
206
+ page: 1,
207
+ })
208
+
209
+ // Automatically syncs filters with URL query
210
+ useContextStorage('query', filters, {
211
+ key: 'filters', // URL will be: ?filters[search]=...&filters[status]=...
212
+ })
213
+ </script>
214
+ ```
215
+
216
+ ## Advanced Usage
217
+
218
+ ### Using Transform Helpers
219
+
220
+ Convert URL query string values to proper types:
221
+
222
+ ```typescript
223
+ import { ref } from 'vue'
224
+ import { useContextStorage, transform } from 'vue-context-storage'
225
+
226
+ interface TableState {
227
+ page: number
228
+ search: string
229
+ perPage: number
230
+ }
231
+
232
+ const state = ref<TableState>({
233
+ page: 1,
234
+ search: '',
235
+ perPage: 25,
236
+ })
237
+
238
+ useContextStorage('query', state, {
239
+ key: 'table',
240
+ transform: (deserialized, initial) => ({
241
+ page: transform.asNumber(deserialized.page, { fallback: 1 }),
242
+ search: transform.asString(deserialized.search, { fallback: '' }),
243
+ perPage: transform.asNumber(deserialized.perPage, { fallback: 25 }),
244
+ }),
245
+ })
246
+ ```
247
+
248
+ ### Available Transform Helpers
249
+
250
+ - `asNumber(value, options)` - Convert to number
251
+ - `asString(value, options)` - Convert to string
252
+ - `asBoolean(value, options)` - Convert to boolean
253
+ - `asArray(value, options)` - Convert to array
254
+ - `asNumberArray(value, options)` - Convert to number array
255
+ - `asObjectArray(value, options)` - Convert indexed object to array of objects (see [Arrays of Objects](#arrays-of-objects))
256
+
257
+ ### Using Zod Schemas
258
+
259
+ Alternatively, you can use [Zod](https://zod.dev/) schemas for automatic validation and type inference:
260
+
261
+ ```typescript
262
+ import { z } from 'zod'
263
+ import { useContextStorage } from 'vue-context-storage'
264
+
265
+ // Define schema with automatic coercion
266
+ const FiltersSchema = z.object({
267
+ search: z.string().default(''),
268
+ page: z.coerce.number().int().positive().default(1),
269
+ status: z.enum(['active', 'inactive']).default('active'),
270
+ })
271
+
272
+ const filters = ref(FiltersSchema.parse({}))
273
+
274
+ // Use schema for automatic validation
275
+ useContextStorage('query', filters, {
276
+ key: 'filters',
277
+ schema: FiltersSchema,
278
+ })
279
+ ```
280
+
281
+ **Benefits:**
282
+
283
+ - Automatic type coercion (strings numbers, etc.)
284
+ - Runtime validation with detailed errors
285
+ - Automatic TypeScript type inference
286
+ - Less boilerplate code
287
+ - Single source of truth for structure and validation
288
+
289
+ ### Arrays of Objects
290
+
291
+ The query handler supports arrays of objects. They are serialized as indexed query parameters:
292
+
293
+ ```
294
+ ?items[0][product]=Apple&items[0][quantity]=5&items[1][product]=Banana&items[1][quantity]=10
295
+ ```
296
+
297
+ After deserialization, URL parameters produce indexed objects (`{ '0': {...}, '1': {...} }`) rather than arrays. Use `transform.asObjectArray` or the Zod helper `zObjectArray` to convert them back.
298
+
299
+ **With transform helpers:**
300
+
301
+ ```typescript
302
+ import { reactive } from 'vue'
303
+ import { useContextStorage, transform } from 'vue-context-storage'
304
+
305
+ const data = reactive({
306
+ title: '',
307
+ items: [] as { product: string; quantity: number }[],
308
+ })
309
+
310
+ useContextStorage('query', data, {
311
+ transform: (value) => ({
312
+ title: transform.asString(value.title),
313
+ items: transform.asObjectArray(value.items, (entry) => ({
314
+ product: transform.asString(entry.product),
315
+ quantity: transform.asNumber(entry.quantity),
316
+ })),
317
+ }),
318
+ })
319
+ ```
320
+
321
+ `asObjectArray` also supports a callback shorthand — pass a function as the second argument instead of an options object.
322
+
323
+ **With Zod schema:**
324
+
325
+ ```typescript
326
+ import { z } from 'zod'
327
+ import { zObjectArray } from 'vue-context-storage/zod'
328
+
329
+ const ItemSchema = z.object({
330
+ product: z.string().default(''),
331
+ quantity: z.coerce.number().default(0),
332
+ })
333
+
334
+ const DataSchema = z.object({
335
+ title: z.string().default(''),
336
+ items: zObjectArray(ItemSchema),
337
+ })
338
+
339
+ useContextStorage('query', data, { schema: DataSchema })
340
+ ```
341
+
342
+ See [Zod Helpers](#zod-helpers-vue-context-storagezod) for more details.
343
+
344
+ ### Preserve Empty State
345
+
346
+ Keep empty state in URL to prevent resetting on reload:
347
+
348
+ ```typescript
349
+ useContextStorage('query', filters, {
350
+ key: 'filters',
351
+ preserveEmptyState: true,
352
+ // Empty filters will show as: ?filters
353
+ // Without this option, empty filters would clear the URL completely
354
+ })
355
+ ```
356
+
357
+ ### Additional Default Data
358
+
359
+ When `onlyChanges` is enabled (the default), a key is omitted from the URL if its current value matches the initial snapshot. `additionalDefaultData` lets you specify extra values that should also be treated as defaults and excluded from the URL.
360
+
361
+ This is useful when the initial reactive data starts with `undefined` (e.g. before an API response), but you also want a specific value (like `1`) to be considered a default:
362
+
363
+ ```typescript
364
+ const data = ref({ page: undefined as number | undefined })
365
+
366
+ useContextStorage('query', data, {
367
+ key: 'filters',
368
+ onlyChanges: true,
369
+ additionalDefaultData: { page: 1 },
370
+ })
371
+
372
+ // page=undefined → not in query (matches initial)
373
+ // page=1 → not in query (matches additionalDefaultData)
374
+ // page=2 → appears in query as ?filters[page]=2
375
+ ```
376
+
377
+ When using Zod schemas, you can also specify `additionalDefaultData` per-field via `.meta()` instead of (or in addition to) the option:
378
+
379
+ ```typescript
380
+ const Schema = z.object({
381
+ page: z.coerce.number().default(1).meta({ additionalDefaultData: 3 }),
382
+ search: z.string().default(''),
383
+ })
384
+
385
+ const data = reactive({ page: undefined as number | undefined, search: '' })
386
+
387
+ useContextStorage('query', data, {
388
+ key: 'filters',
389
+ schema: Schema,
390
+ })
391
+
392
+ // page=1 → not in query (matches default from schema)
393
+ // page=2 → appears in query as ?filters[page]=2
394
+ // page=3 → not in query (matches additionalDefaultData from schema meta)
395
+ ```
396
+
397
+ A field's schema `.default(...)` value is itself treated as a default baseline: a value equal to it is omitted from the URL, exactly like the initial snapshot and `additionalDefaultData`. So both the `.default()` value and any `.meta({ additionalDefaultData })` value are excluded, while every other value appears in the query.
398
+
399
+ Each source of defaults is an **independent baseline** — the initial snapshot, the option-level `additionalDefaultData`, the schema `.meta({ additionalDefaultData })`, and the schema `.default()`. A value is omitted from the URL if it matches **any** of them, so they never overwrite each other (e.g. a field can have a `.default()` of `1`, a meta value of `5`, and an option value of `3`, and all three are excluded).
400
+
401
+ Nested objects work the same way. Following the documented best practice of declaring `.default()` at the object level, a value matching the nested default is omitted too:
402
+
403
+ ```typescript
404
+ const Schema = z.object({
405
+ filters: z
406
+ .object({
407
+ page: z.coerce.number(),
408
+ sort: z.string(),
409
+ })
410
+ .default({ page: 1, sort: 'asc' }),
411
+ })
412
+
413
+ const data = reactive({
414
+ filters: { page: undefined as number | undefined, sort: undefined as string | undefined },
415
+ })
416
+
417
+ useContextStorage('query', data, { key: 'q', schema: Schema })
418
+
419
+ // filters = { page: 1, sort: 'asc' } → not in query (matches the nested default)
420
+ // filters = { page: 2, sort: 'asc' } → ?q[filters][page]=2 (sort matches default, omitted)
421
+ // filters = { page: 2, sort: 'desc' } → ?q[filters][page]=2&q[filters][sort]=desc
422
+ ```
423
+
424
+ Field-level defaults inside a nested object take priority over the object-level default and fill in any fields the object-level default omits.
425
+
426
+ ### Configure Query Handler
427
+
428
+ Customize behavior by passing options to the factory:
429
+
430
+ ```vue
431
+ <template>
432
+ <!-- Override a single default handler without listing all of them -->
433
+ <ContextStorage :additional-handlers="[createQueryHandler({ mode: 'push' })]">
434
+ <RouterView />
435
+ </ContextStorage>
436
+ </template>
437
+ ```
438
+
439
+ The `additional-handlers` prop merges with the default handlers, replacing any handler of the same type (matched by injection key). This is the recommended way to customize a single handler.
440
+
441
+ If you need full control over all handlers, use the `handlers` prop instead:
442
+
443
+ ```typescript
444
+ import {
445
+ createQueryHandler,
446
+ createLocalStorageHandler,
447
+ createSessionStorageHandler,
448
+ } from 'vue-context-storage'
449
+
450
+ const customHandlers = [
451
+ createQueryHandler({
452
+ mode: 'push', // 'replace' (default) or 'push' for history
453
+ preserveUnusedKeys: false, // Default is true — set to false for exclusive query ownership
454
+ preserveEmptyState: false,
455
+ }),
456
+ createLocalStorageHandler(),
457
+ createSessionStorageHandler(),
458
+ ]
459
+
460
+ // Pass to ContextStorage or ContextStorageCollection component:
461
+ // <ContextStorage :handlers="customHandlers">
462
+ ```
463
+
464
+ ## Use localStorage Handler in Components
465
+
466
+ Persist reactive state to `localStorage`. Data is automatically synced across browser tabs.
467
+
468
+ ```vue
469
+ <script setup lang="ts">
470
+ import { reactive } from 'vue'
471
+ import { useContextStorage } from 'vue-context-storage'
472
+
473
+ const settings = reactive({
474
+ theme: 'light',
475
+ fontSize: 14,
476
+ sidebarOpen: true,
477
+ })
478
+
479
+ // Automatically syncs settings with localStorage under the key "app-settings"
480
+ useContextStorage('localStorage', settings, {
481
+ key: 'app-settings',
482
+ })
483
+ </script>
484
+ ```
485
+
486
+ ### Configure localStorage Handler
487
+
488
+ ```typescript
489
+ import { createLocalStorageHandler } from 'vue-context-storage'
490
+
491
+ const customLocalStorage = createLocalStorageHandler({
492
+ listenToStorageEvents: true, // Cross-tab sync (default: true)
493
+ })
494
+ ```
495
+
496
+ ## Use sessionStorage Handler in Components
497
+
498
+ Persist reactive state to `sessionStorage`. Data survives page refreshes but is cleared when the tab is closed.
499
+
500
+ ```vue
501
+ <script setup lang="ts">
502
+ import { reactive } from 'vue'
503
+ import { useContextStorage } from 'vue-context-storage'
504
+
505
+ const formDraft = reactive({
506
+ email: '',
507
+ message: '',
508
+ step: 1,
509
+ })
510
+
511
+ // Automatically syncs form draft with sessionStorage
512
+ useContextStorage('sessionStorage', formDraft, {
513
+ key: 'contact-form-draft',
514
+ })
515
+ </script>
516
+ ```
517
+
518
+ ### Multiple Registrations Under One Root Key
519
+
520
+ Use bracket notation in `key` to store multiple data objects under a common root:
521
+
522
+ ```typescript
523
+ const filters = reactive({ search: '', status: 'active' })
524
+
525
+ useContextStorage('sessionStorage', filters, {
526
+ key: 'app-state[filters]', // Storage key: 'app-state[filters]'
527
+ })
528
+
529
+ const pagination = reactive({ page: 1, perPage: 25 })
530
+
531
+ useContextStorage('sessionStorage', pagination, {
532
+ key: 'app-state[pagination]', // Storage key: 'app-state[pagination]'
533
+ })
534
+ ```
535
+
536
+ Or use `<ContextStoragePrefix>` for automatic scoping (see [Prefix Scoping](#prefix-scoping-with-contextstorageprefix)).
537
+
538
+ ### Using Transform with Storage Handlers
539
+
540
+ Convert stored values to proper types when reading from storage:
541
+
542
+ ```typescript
543
+ import { useContextStorage, transform } from 'vue-context-storage'
544
+
545
+ const settings = reactive({
546
+ theme: 'light',
547
+ fontSize: 14,
548
+ })
549
+
550
+ useContextStorage('localStorage', settings, {
551
+ key: 'app-settings',
552
+ transform: (deserialized, initial) => ({
553
+ theme: transform.asString(deserialized.theme, { fallback: 'light' }),
554
+ fontSize: transform.asNumber(deserialized.fontSize, { fallback: 14 }),
555
+ }),
556
+ })
557
+ ```
558
+
559
+ ### Using Zod Schemas with Storage Handlers
560
+
561
+ ```typescript
562
+ import { z } from 'zod'
563
+ import { useContextStorage } from 'vue-context-storage'
564
+
565
+ const SettingsSchema = z.object({
566
+ theme: z.enum(['light', 'dark']).default('light'),
567
+ fontSize: z.number().int().positive().default(14),
568
+ sidebarOpen: z.boolean().default(true),
569
+ })
570
+
571
+ const settings = reactive(SettingsSchema.parse({}))
572
+
573
+ useContextStorage('localStorage', settings, {
574
+ key: 'app-settings',
575
+ schema: SettingsSchema,
576
+ })
577
+ ```
578
+
579
+ ### Custom Serialization
580
+
581
+ Provide custom serializer/deserializer functions:
582
+
583
+ ```typescript
584
+ useContextStorage('localStorage', settings, {
585
+ key: 'app-settings',
586
+ serializer: (data) => btoa(JSON.stringify(data)),
587
+ deserializer: (str) => JSON.parse(atob(str)),
588
+ })
589
+ ```
590
+
591
+ ## Prefix Scoping with `<ContextStoragePrefix>`
592
+
593
+ The `<ContextStoragePrefix>` component adds a prefix to all `useContextStorage` calls within its subtree. Prefixes stack when nested, and are concatenated with bracket notation.
594
+
595
+ ### Basic Usage
596
+
597
+ ```vue
598
+ <template>
599
+ <ContextStoragePrefix name="table">
600
+ <MyTable />
601
+ </ContextStoragePrefix>
602
+ </template>
603
+ ```
604
+
605
+ Inside `MyTable`, any `useContextStorage('query', data)` call will automatically get `key: 'tables'`. If the composable also specifies its own key, they are combined:
606
+
607
+ ```typescript
608
+ // Inside MyTable — effective key becomes 'table[filters]'
609
+ useContextStorage('query', filters, { key: 'filters' })
610
+ // URL: ?table[filters][search]=...
611
+ ```
612
+
613
+ ### Stacking Prefixes
614
+
615
+ Nested `<ContextStoragePrefix>` components stack their prefixes:
616
+
617
+ ```vue
618
+ <ContextStoragePrefix name="tables">
619
+ <ContextStoragePrefix name="first">
620
+ <!-- All handlers here get prefix 'tables[first]' -->
621
+ <!-- useContextStorage('query', data) → URL: ?tables[first][search]=... -->
622
+ <!-- useContextStorage('localStorage', data, { key: 'state' }) → key: 'state[tables][first]' -->
623
+ </ContextStoragePrefix>
624
+ </ContextStoragePrefix>
625
+ ```
626
+
627
+ ### Per-Handler Prefixes
628
+
629
+ Pass an object to apply different prefixes per handler type:
630
+
631
+ ```vue
632
+ <ContextStoragePrefix :name="{ query: 'url-tables', localStorage: 'ls-data' }">
633
+ <!-- query handler gets prefix 'url-tables' -->
634
+ <!-- localStorage handler gets prefix 'ls-data' -->
635
+ <!-- sessionStorage handler gets no prefix (not specified) -->
636
+ </ContextStoragePrefix>
637
+ ```
638
+
639
+ ### Dynamic Prefix
640
+
641
+ When the `name` prop changes, all descendant components are re-created and re-registered with the new prefix:
642
+
643
+ ```vue
644
+ <ContextStoragePrefix :name="activeTab">
645
+ <TabContent />
646
+ </ContextStoragePrefix>
647
+ ```
648
+
649
+ ## Registering Custom Handlers
650
+
651
+ Register your own handlers at runtime and extend the type map for full type safety:
652
+
653
+ ```typescript
654
+ import { defineContextStorageHandler } from 'vue-context-storage'
655
+ import { myHandlerInjectionKey } from './my-handler'
656
+
657
+ // Runtime registration
658
+ defineContextStorageHandler('myHandler', myHandlerInjectionKey)
659
+
660
+ // TypeScript augmentation (e.g. in a .d.ts or at module level)
661
+ declare module 'vue-context-storage' {
662
+ interface ContextStorageHandlerMap {
663
+ myHandler: { key: string }
664
+ }
665
+ }
666
+
667
+ // Now fully type-checked
668
+ useContextStorage('myHandler', data, { key: 'example' })
669
+ ```
670
+
671
+ ## API Reference
672
+
673
+ ### Composables
674
+
675
+ #### `useContextStorage(type, data, options)`
676
+
677
+ Unified composable that delegates to the correct handler based on `type`.
678
+
679
+ **Parameters:**
680
+
681
+ - `type: 'query' | 'localStorage' | 'sessionStorage' | InjectionKey` - Handler type or injection key
682
+ - `data: MaybeRefOrGetter<T>` - Reactive reference to sync
683
+ - `options` - Handler-specific options (type-checked per handler)
684
+
685
+ **Returns:** `{ data, stop, reset, wasChanged }`
686
+
687
+ - `data` - The reactive reference passed in
688
+ - `stop()` - Unregister and stop syncing (called automatically on unmount)
689
+ - `reset()` - Restore data to its initial state
690
+ - `wasChanged: ComputedRef<boolean>` - Whether data differs from initial state
691
+
692
+ **Custom handler registration:**
693
+
694
+ - `defineContextStorageHandler(name, injectionKey)` - Register a custom handler
695
+ - `resolveHandlerInjectionKey(type)` - Look up an injection key by name
696
+
697
+ ### Handler Factories
698
+
699
+ #### `createQueryHandler(options?)`
700
+
701
+ Creates a query handler factory for URL query synchronization.
702
+
703
+ **Options:**
704
+
705
+ - `mode?: 'replace' | 'push'` - Router navigation mode (default: `'replace'`)
706
+ - `preserveUnusedKeys?: boolean` - Keep other query params (default: `true`)
707
+ - `preserveEmptyState?: boolean` - Preserve empty state in URL (default: `false`)
708
+ - `emptyPlaceholder?: string` - Placeholder for empty state (default: `'_'`)
709
+ - `onlyChanges?: boolean` - Only write changed values to URL (default: `true`)
710
+
711
+ #### `createLocalStorageHandler(options?)`
712
+
713
+ Creates a localStorage handler factory.
714
+
715
+ **Options:**
716
+
717
+ - `listenToStorageEvents?: boolean` - Enable cross-tab sync (default: `true`)
718
+
719
+ #### `createSessionStorageHandler(options?)`
720
+
721
+ Creates a sessionStorage handler factory.
722
+
723
+ **Options:**
724
+
725
+ - `listenToStorageEvents?: boolean` - Listen to storage events (default: `false`)
726
+
727
+ ### Components
728
+
729
+ #### `<ContextStoragePrefix>`
730
+
731
+ Scopes a prefix for all descendant `useContextStorage` calls via provide/inject.
732
+
733
+ **Props:**
734
+
735
+ - `name: string | Partial<Record<string, string>>` (required) - Prefix to apply. A string applies to all handlers; an object applies per handler type (e.g. `{ query: 'q', localStorage: 'ls' }`)
736
+
737
+ Nested `<ContextStoragePrefix>` components stack their prefixes using bracket notation. When `name` changes dynamically, all descendant components are re-created.
738
+
739
+ ### Transform Helpers
740
+
741
+ All transform helpers support nullable and missable options:
742
+
743
+ ```typescript
744
+ transform.asNumber(value, {
745
+ fallback: 0, // Default value
746
+ nullable: false, // Allow null return
747
+ missable: false, // Allow undefined return
748
+ })
749
+ ```
750
+
751
+ ## Zod Helpers (`vue-context-storage/zod`)
752
+
753
+ The library provides a separate entry point with Zod-specific helpers. Since `zod` is an optional peer dependency, these helpers are isolated in `vue-context-storage/zod` to avoid importing Zod in the main bundle.
754
+
755
+ ```bash
756
+ npm install zod
757
+ ```
758
+
759
+ ### `zObjectArray(itemSchema)`
760
+
761
+ Creates a Zod schema for arrays of objects serialized as indexed query parameters. Wraps `z.record()` + `.transform()` to convert indexed objects back to sorted arrays.
762
+
763
+ ```typescript
764
+ import { z } from 'zod'
765
+ import { zObjectArray } from 'vue-context-storage/zod'
766
+
767
+ const ItemSchema = z.object({
768
+ product: z.string().default(''),
769
+ quantity: z.coerce.number().default(0),
770
+ })
771
+
772
+ const DataSchema = z.object({
773
+ title: z.string().default(''),
774
+ items: zObjectArray(ItemSchema),
775
+ })
776
+ ```
777
+
778
+ ### Automatic Type Coercion
779
+
780
+ When a `schema` is provided, the library automatically coerces URL query parameter values to match the expected Zod types before validation. This handles two common URL serialization quirks:
781
+
782
+ #### Array Coercion
783
+
784
+ A URL with multiple values (`?ids=1&ids=2`) produces an array `['1', '2']`, but a single value (`?ids=1`) produces just `'1'`. Without correction, `z.string().array()` would reject the single-value case with `"expected array, received string"`.
785
+
786
+ The library introspects the Zod schema before validation and wraps non-array values into single-element arrays wherever the schema expects `.array()`. This works recursively for nested objects. **No special helpers are needed** — plain `.array()` schemas work out of the box:
787
+
788
+ ```typescript
789
+ const Schema = z.object({
790
+ tags: z.string().array().default([]),
791
+ ids: z.coerce.number().array().default([]),
792
+ statuses: z.enum(['active', 'inactive']).array().default([]),
793
+ filters: z
794
+ .object({
795
+ categories: z.string().array().default([]),
796
+ })
797
+ .default({ categories: [] }),
798
+ })
799
+
800
+ // All of these work automatically:
801
+ // ?tags=vue → { tags: ['vue'], ... }
802
+ // ?tags=vue&tags=ts { tags: ['vue', 'ts'], ... }
803
+ // (no tags param) { tags: [], ... }
804
+ ```
805
+
806
+ #### Boolean Coercion
807
+
808
+ The query handler serializes booleans as `'1'`/`'0'` strings in URL parameters (e.g. `?active=1`). Standard `z.coerce.boolean()` cannot be used because `Boolean('0')` is `true` in JavaScript. The library automatically converts `'1'` → `true` and `'0'` → `false` when the schema expects a boolean field. **No special helpers are needed** — plain `z.boolean()` works out of the box:
809
+
810
+ ```typescript
811
+ const Schema = z.object({
812
+ active: z.boolean().default(false),
813
+ enabled: z.boolean().default(true),
814
+ })
815
+
816
+ // ?active=1 → { active: true, enabled: true }
817
+ // ?active=0 → { active: false, enabled: true }
818
+ ```
819
+
820
+ ### `createSchemaObject(schema, options?)`
821
+
822
+ Creates a plain object with empty/default values based on a Zod schema. Useful for initializing reactive data from a schema definition.
823
+
824
+ ```typescript
825
+ import { z } from 'zod'
826
+ import { createSchemaObject } from 'vue-context-storage/zod'
827
+
828
+ const FiltersSchema = z.object({
829
+ search: z.string().default(''),
830
+ page: z.coerce.number().default(1),
831
+ active: z.boolean().default(false),
832
+ score: z.number().nullable(),
833
+ })
834
+
835
+ const filters = reactive(createSchemaObject(FiltersSchema))
836
+ // Result: { search: '', page: 1, active: false, score: null }
837
+ ```
838
+
839
+ **Options:**
840
+
841
+ - `useDefaults` (default: `true`) When `true`, uses `.default()` values from the schema. When `false`, uses type-based empty values (`''` for strings, `0` for numbers, `false` for booleans, etc.).
842
+ - `withSchema` (default: `false`) — When `true`, attaches the schema to the result object via `SCHEMA_SYMBOL` (wrapped with `markRaw`). Nested objects also receive their respective schemas.
843
+
844
+ ```typescript
845
+ import { createSchemaObject, SCHEMA_SYMBOL } from 'vue-context-storage/zod'
846
+
847
+ const data = createSchemaObject(FiltersSchema, { withSchema: true })
848
+ data[SCHEMA_SYMBOL] // → FiltersSchema
849
+ ```
850
+
851
+ **Type-based defaults** (when `useDefaults: false` or no `.default()` is set):
852
+
853
+ | Zod type | Default value |
854
+ | ------------- | -------------------------------------------- |
855
+ | `z.string()` | `''` |
856
+ | `z.number()` | `0` (respects `.min()` / `.positive()`) |
857
+ | `z.boolean()` | `false` |
858
+ | `z.array()` | `[]` |
859
+ | `z.object()` | Recursively created via `createSchemaObject` |
860
+ | `z.date()` | `null` |
861
+ | `.nullable()` | `null` |
862
+ | `.optional()` | `undefined` |
863
+
864
+ ## TypeScript Support
865
+
866
+ Full TypeScript support with type inference:
867
+
868
+ ```typescript
869
+ import type {
870
+ ContextStorageHandler,
871
+ ContextStorageHandlerFactory,
872
+ QueryValue,
873
+ } from 'vue-context-storage'
874
+ ```
875
+
876
+ When using Zod schemas, TypeScript will automatically infer types:
877
+
878
+ ```typescript
879
+ const FiltersSchema = z.object({
880
+ search: z.string().default(''),
881
+ page: z.coerce.number().default(1),
882
+ })
883
+
884
+ type Filters = z.infer<typeof FiltersSchema>
885
+ // Result: { search: string; page: number }
886
+ ```
887
+
888
+ ## Examples
889
+
890
+ ### Pagination with URL Sync
891
+
892
+ ```typescript
893
+ import { ref } from 'vue'
894
+ import { useContextStorage, transform } from 'vue-context-storage'
895
+
896
+ const pagination = ref({
897
+ page: 1,
898
+ perPage: 25,
899
+ total: 0,
900
+ })
901
+
902
+ useContextStorage('query', pagination, {
903
+ key: 'page',
904
+ transform: (data, initial) => ({
905
+ page: transform.asNumber(data.page, { fallback: 1 }),
906
+ perPage: transform.asNumber(data.perPage, { fallback: 25 }),
907
+ total: initial.total, // Don't sync total from URL
908
+ }),
909
+ })
910
+ ```
911
+
912
+ ## Peer Dependencies
913
+
914
+ - `vue`: ^3.0.0
915
+ - `vue-router`: ^4.0.0 || ^5.0.0
916
+ - `zod`: ^4.0.0 (optional - only if using schema validation)
917
+
918
+ ## License
919
+
920
+ MIT
921
+
922
+ ## Development
923
+
924
+ ### Running Playground Locally
925
+
926
+ ```bash
927
+ # Development mode (hot reload)
928
+ npm run play
929
+
930
+ # Production preview
931
+ npm run build:playground
932
+ npm run preview:playground
933
+ ```
934
+
935
+ ### Building
936
+
937
+ ```bash
938
+ # Build library
939
+ npm run build
940
+
941
+ # Build playground for deployment
942
+ npm run build:playground
943
+ ```
944
+
945
+ ### Testing & Quality
946
+
947
+ ```bash
948
+ # Run all checks
949
+ npm run check
950
+
951
+ # Type checking
952
+ npm run ts:check
953
+
954
+ # Linting
955
+ npm run lint
956
+
957
+ # Formatting
958
+ npm run format
959
+ ```
960
+
961
+ ## Contributing
962
+
963
+ Contributions are welcome! Please feel free to submit a Pull Request.