vue-context-storage 0.1.42 → 0.1.43

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