@wcstack/storage
v1.19.1
Published
Declarative persistent storage component for Web Components. Framework-agnostic localStorage/sessionStorage via wc-bindable-protocol.
Maintainers
Readme
@wcstack/storage
@wcstack/storage is a headless storage component for the wcstack ecosystem.
It is not a visual UI widget. It is an I/O node that connects browser storage (localStorage / sessionStorage) to reactive state.
When combined with @wcstack/state, <wcs-storage> can be bound directly through a path contract:
- Input / Command Surface:
key,type,trigger - Output State Surface:
value,loading,error
This means you can express browser storage persistence declaratively in HTML, without writing localStorage.getItem(), JSON.parse(), or serialization glue code in the UI layer.
@wcstack/storage follows the CSBC (Core / Shell / Binding Contract) architecture:
- Core (
StorageCore) handles storage read/write and cross-tab sync - Shell (
<wcs-storage>) connects that state to the DOM - Frameworks and binding systems consume it via the wc-bindable-protocol
Why this exists
Frontend applications frequently use localStorage / sessionStorage for persisting user settings and session data. Yet the glue code — reading, JSON parsing, saving, error handling — follows the same pattern every time.
@wcstack/storage moves that glue code into a reusable component and exposes the stored value as bindable state.
The flow with @wcstack/state:
<wcs-storage>auto-loads from storage on connectionvalueis bound to the UI viadata-wcs- State changes are automatically written back to storage
- Changes from other tabs are automatically detected
Persistence becomes a state transition, not imperative glue code.
Install
npm install @wcstack/storageQuick Start
1. Primitive value auto-save
Primitive values (strings, numbers, booleans) work with just a value binding for two-way persistence.
<script type="module" src="https://esm.run/@wcstack/state/auto"></script>
<script type="module" src="https://esm.run/@wcstack/storage/auto"></script>
<wcs-state>
<script type="module">
export default {
// undefined on purpose — a "" / null initial would be written back
// through the two-way binding and overwrite the saved value on
// reload (see "5. Load-before-bind" below)
username: undefined,
};
</script>
</wcs-state>
<wcs-storage key="username" data-wcs="value: username"></wcs-storage>
<input data-wcs="value: username" placeholder="Username">
<p>Saved: <span data-wcs="textContent: username"></span></p>This is the default mode:
- Set a
keyto auto-load on connection - Bind to
valuefor two-way persistence - Optionally bind
loadinganderroras well - Start the bound state slot as
undefined— see 5. Load-before-bind for the full idiom
2. Persisting objects with $trackDependency
When sub-properties of an object (e.g. settings.theme) change, the parent path settings binding does not fire.
This is because @wcstack/state's dependency walk is parent → child only.
In this case, use $trackDependency to explicitly list the sub-properties to watch, and save via trigger:
<wcs-state>
<script type="module">
export default defineState({
settings: { theme: "light", lang: "en" },
get settingsChanged() {
this.$trackDependency("settings.theme");
this.$trackDependency("settings.lang");
return true;
},
});
</script>
</wcs-state>
<wcs-storage key="app-settings" manual
data-wcs="value: settings; trigger: settingsChanged">
</wcs-storage>
<select data-wcs="value: settings.theme">
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<select data-wcs="value: settings.lang">
<option value="en">English</option>
<option value="ja">日本語</option>
</select>Flow:
- User changes theme →
settings.themeupdates - Dynamic dependency triggers
settingsChangedre-evaluation → returnstrue trigger: settingsChangedbinding fires →save()executes- The entire
settingsobject is saved to localStorage
3. Using sessionStorage
Use type="session" for sessionStorage:
<wcs-state>
<script type="module">
export default { sessionData: null };
</script>
</wcs-state>
<wcs-storage key="session-data" type="session"
data-wcs="value: sessionData">
</wcs-storage>
<p data-wcs="textContent: sessionData"></p>4. Cross-tab sync
localStorage changes are automatically detected from other tabs:
<wcs-state>
<script type="module">
export default {
// undefined on purpose — see "5. Load-before-bind" below
sharedCounter: undefined,
};
</script>
</wcs-state>
<wcs-storage key="shared-counter"
data-wcs="value: sharedCounter">
</wcs-storage>
<!-- Changes from other tabs update this value automatically -->
<p data-wcs="textContent: sharedCounter"></p>Note: The
storageevent only fires for changes made in other tabs of the same origin. Since sessionStorage is not shared across tabs, cross-tab sync only works with localStorage.
5. Load-before-bind: the persistent-slot idiom
<wcs-storage> loads and announces the persisted value in its own connectedCallback. Depending on script loading, that can happen before <wcs-state> finishes attaching its bindings — with two consequences:
- Clobber: if the bound state slot starts as
""/0/null/[], the initial state→element apply writes that value intovalue, and the write-through save overwrites the persisted data on every reload. - Missed load: the value event announcing the loaded data fired before anyone was listening, so the state slot can stay at its initial value.
The idiom that closes both:
<wcs-state>
<script type="module">
export default {
// 1. undefined = "no opinion": the initial apply is skipped,
// so the persisted value is never clobbered
todos: undefined,
// reads go through a normalizing getter
get list() {
return Array.isArray(this.todos) ? this.todos : [];
},
// 2. pull the value <wcs-storage> already loaded, once
$connectedCallback() {
(async () => {
await customElements.whenDefined("wcs-storage");
const el = document.querySelector("wcs-storage");
if (!el) return;
await el.connectedCallbackPromise;
if (!Array.isArray(this.todos) && Array.isArray(el.value)) {
this.todos = el.value;
}
})();
},
};
</script>
</wcs-state>
<wcs-storage key="todos" type="local" data-wcs="value: todos"></wcs-storage>- Rule of thumb: a state slot bound two-way to
valuemust start asundefined— never""/0/null/[]. - The
$connectedCallbackpull is only needed when the persisted value must render on first paint. If the slot is only ever written after user interaction,undefinedalone is enough. - Working examples:
examples/state-cross-tab-todo,examples/state-color-palette.
State Surface vs Command Surface
<wcs-storage> exposes two kinds of properties.
Output State (bindable state)
Represents the current storage value and is the CSBC main surface:
| Property | Type | Description |
|----------|------|-------------|
| value | any | Value stored in storage |
| loading | boolean | true during read/write |
| error | WcsStorageError \| Error \| null | Storage operation error |
Input / Command Surface
Controls storage operations from HTML, JS, or @wcstack/state bindings:
| Property | Type | Description |
|----------|------|-------------|
| key | string | Storage key |
| type | "local" \| "session" | Storage type |
| value | any | Setting this auto-saves (when not manual) |
| trigger | boolean | One-way save trigger |
| manual | boolean | Disables auto-load and auto-save |
CSS styling with :state()
<wcs-storage> reflects two boolean output states onto its
ElementInternals CustomStateSet,
so you can style it directly from CSS with the :state() pseudo-class — no
data-wcs binding or extra class toggling required.
| State | On when |
|-------|---------|
| loading | wcs-storage:loading-changed fires with true (cleared on false) |
| error | wcs-storage:error fires with a non-null detail (cleared on null) |
wcs-storage:state(loading) ~ .spinner { display: block; }
wcs-storage:state(loading) ~ .spinner { display: none; } /* default */
form:has(wcs-storage:state(error)) .banner { display: block; }Unlike attributes or classes, :state() cannot be written from outside the
element, so there is no risk of confusing this output state with an input.
Browser support (:state(x) syntax): Chrome/Edge 125+, Safari 17.4+,
Firefox 126+. In older browsers the states are simply never set — :state()
selectors never match, but <wcs-storage> itself keeps working normally
(graceful degradation, never-throw).
SSR: :state() cannot be serialized into HTML, so server-rendered markup
never carries these states on first paint (@wcstack/server is unaffected).
If you need to style the pre-hydration gap, pair your rule with
wcs-storage:not(:defined) instead.
Debugging
Custom states are invisible in DevTools' Elements panel and attachInternals()
cannot be called twice, so there is no console way to inspect them directly.
Two debug-only aids are provided for that:
el.debugStates— a snapshot array of the currently-on state names (e.g.["loading"]). It is not part ofwc-bindable(not a bind target) and its shape is not a guaranteed contract — use it for debugging only.The
debug-statesattribute (opt-in, default off) mirrors state changes ontodata-wcs-state-loading/data-wcs-state-errorattributes on the element, so the Elements panel highlights them as they toggle:<wcs-storage key="prefs" debug-states></wcs-storage>
Write your CSS against :state(), not data-wcs-state-*. The mirrored
attributes exist purely to make state changes visible while debugging with
DevTools open; they are not a supported styling hook.
Architecture
@wcstack/storage follows the CSBC architecture.
Core: StorageCore
StorageCore is a pure EventTarget class. It encapsulates:
- Storage read, write, and remove
- Automatic JSON serialization / deserialization
- Cross-tab sync via
storageevent wc-bindable-protocoldeclaration
It works headlessly in any runtime that supports EventTarget and localStorage / sessionStorage.
Shell: <wcs-storage>
<wcs-storage> is a thin HTMLElement wrapper around StorageCore. It adds:
- Attribute / property mapping
- DOM lifecycle integration (auto-load on connect, cleanup on disconnect)
- Auto-save via the
valuesetter - Declarative execution helpers like
trigger
This separation keeps storage logic portable while enabling natural integration with DOM-based binding systems like @wcstack/state.
Target injection
Core uses target injection to fire events directly on the Shell, avoiding event re-dispatch.
Headless usage (Core only)
StorageCore can be used standalone without DOM. It declares static wcBindable, so you can subscribe to state with bind() from @wc-bindable/core — the same mechanism used by framework adapters:
import { StorageCore } from "@wcstack/storage";
import { bind } from "@wc-bindable/core";
const core = new StorageCore();
const unbind = bind(core, (name, value) => {
console.log(`${name}:`, value);
});
core.key = "my-data";
core.load();
unbind();Auto JSON serialization
StorageCore automatically serializes and deserializes based on data type:
| Type on save | Format in storage | Type on load |
|-------------|-------------------|-------------|
| Object / Array | JSON.stringify() result | JSON.parse() result |
| String | As-is | Parsed if valid JSON, otherwise the raw string |
| Number / boolean | JSON.stringify() result | JSON.parse() result |
| null / undefined | Key removed | null |
Element Reference
<wcs-storage>
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| key | string | — | Storage key |
| type | "local" \| "session" | local | Storage type |
| manual | boolean | false | Disables auto-load and auto-save |
| Property | Type | Description |
|----------|------|-------------|
| value | any | Storage value (auto-saves on set) |
| loading | boolean | true during read/write |
| error | WcsStorageError \| Error \| null | Error info |
| trigger | boolean | Set true to execute save |
| manual | boolean | Manual mode |
| Method | Description |
|--------|-------------|
| load() | Load value from storage |
| save() | Save current value to storage |
| remove() | Remove key from storage |
wc-bindable-protocol
Both StorageCore and <wcs-storage> conform to the wc-bindable-protocol, enabling interop with any protocol-aware framework or component.
The declaration follows the full wc-bindable interface model — three independent surfaces:
properties— observable outputs thatbind()subscribes to (value,loading,error, and the Shell'strigger)inputs— the settable surface (key,type, …); declarative metadata that tooling, codegen, and remote proxying readcommands— invocable methods (load,save,remove); a binding system such as@wcstack/statecan invoke them by name
Per the protocol, only properties is interpreted by core bind(); inputs / commands (and the attribute / async hints) are descriptive. They do not create implicit two-way data flow.
Core (StorageCore)
StorageCore declares the bindable state any runtime can subscribe to, plus its portable input/command surface:
static wcBindable = {
protocol: "wc-bindable",
version: 1,
properties: [
{ name: "value", event: "wcs-storage:value-changed",
getter: (e) => e.detail },
{ name: "loading", event: "wcs-storage:loading-changed" },
{ name: "error", event: "wcs-storage:error" },
],
inputs: [
{ name: "key" },
{ name: "type" },
],
commands: [
{ name: "load" },
{ name: "save" },
{ name: "remove" },
],
};Headless consumers call core.load() / core.save(value) directly — no trigger needed.
Shell (<wcs-storage>)
The Shell extends the Core declaration with the trigger output and the DOM-driven input surface; commands (load / save / remove) are inherited unchanged:
static wcBindable = {
...StorageCore.wcBindable,
properties: [
...StorageCore.wcBindable.properties,
{ name: "trigger", event: "wcs-storage:trigger-changed" },
],
inputs: [
{ name: "key" },
{ name: "type" },
{ name: "value" },
{ name: "manual" },
{ name: "trigger" },
],
};The Shell's inputs intentionally carry no attribute hint: the key / type / manual setters already reflect to their attributes, so a binding system that mirrors inputs[].attribute would set the attribute twice.
TypeScript Types
import type {
WcsStorageError, WcsStorageCoreValues, WcsStorageValues, StorageType
} from "@wcstack/storage";type StorageType = "local" | "session";
// Storage operation error
interface WcsStorageError {
operation: "load" | "save" | "remove" | "type";
message: string;
}
// Core (headless) — 3 state properties
interface WcsStorageCoreValues<T = unknown> {
value: T;
loading: boolean;
error: WcsStorageError | Error | null;
}
// Shell (<wcs-storage>) — extends Core with trigger
interface WcsStorageValues<T = unknown> extends WcsStorageCoreValues<T> {
trigger: boolean;
}Why it works well with @wcstack/state
@wcstack/state uses path strings as the sole contract between UI and state.
<wcs-storage> fits naturally into this model:
<wcs-storage>auto-loads from storage on connectionvalueis bound to a state path, reflected in the UI- User interactions update state, which auto-saves back to storage
- State survives page reloads
- The bound slot starts as
undefined(Quick Start 5), so a reload never overwrites what was saved
Persistence looks just like any other state update.
Framework Integration
<wcs-storage> is CSBC + wc-bindable-protocol, so it works in any framework via thin @wc-bindable/* adapters.
React
import { useWcBindable } from "@wc-bindable/react";
import type { WcsStorageValues } from "@wcstack/storage";
interface Settings { theme: string; lang: string; }
function SettingsPanel() {
const [ref, { value: settings, loading, error }] =
useWcBindable<HTMLElement, WcsStorageValues<Settings>>();
return (
<>
<wcs-storage ref={ref} key="app-settings" />
{loading && <p>Loading...</p>}
{settings && <p>Theme: {settings.theme}</p>}
</>
);
}Vue
<script setup lang="ts">
import { useWcBindable } from "@wc-bindable/vue";
import type { WcsStorageValues } from "@wcstack/storage";
interface Settings { theme: string; lang: string; }
const { ref, values } = useWcBindable<HTMLElement, WcsStorageValues<Settings>>();
</script>
<template>
<wcs-storage :ref="ref" key="app-settings" />
<p v-if="values.loading">Loading...</p>
<p v-else-if="values.value">Theme: {{ values.value.theme }}</p>
</template>Svelte
<script>
import { wcBindable } from "@wc-bindable/svelte";
let settings = $state(null);
let loading = $state(false);
</script>
<wcs-storage key="app-settings"
use:wcBindable={{ onUpdate: (name, v) => {
if (name === "value") settings = v;
if (name === "loading") loading = v;
}}} />
{#if loading}
<p>Loading...</p>
{:else if settings}
<p>Theme: {settings.theme}</p>
{/if}Solid
import { createWcBindable } from "@wc-bindable/solid";
import type { WcsStorageValues } from "@wcstack/storage";
interface Settings { theme: string; lang: string; }
function SettingsPanel() {
const [values, directive] = createWcBindable<WcsStorageValues<Settings>>();
return (
<>
<wcs-storage ref={directive} key="app-settings" />
<Show when={!values.loading} fallback={<p>Loading...</p>}>
<p>Theme: {values.value?.theme}</p>
</Show>
</>
);
}Vanilla — direct bind()
import { bind } from "@wc-bindable/core";
const storageEl = document.querySelector("wcs-storage");
bind(storageEl, (name, value) => {
console.log(`${name} changed:`, value);
});Optional DOM Triggering
When autoTrigger is enabled (default), clicking an element with a data-storagetarget attribute executes save() on the corresponding <wcs-storage>:
<button data-storagetarget="settings-store">Save Settings</button>
<wcs-storage id="settings-store" key="settings" manual
data-wcs="value: settings"></wcs-storage>Configuration
import { bootstrapStorage } from "@wcstack/storage";
bootstrapStorage({
autoTrigger: true,
triggerAttribute: "data-storagetarget",
tagNames: {
storage: "wcs-storage",
},
});Design Notes
value,loading,errorare output statekey,type,triggerare input / command surfacetriggeris intentionally one-way: writingtruesaves, reset signals completion.save()is never-throw (a failure such as an unsetkeyis routed to theerrorproperty, not thrown);triggeris reset tofalseand the completion event fires regardless, so it never gets stuck in thetruestate.- The
valuesetter auto-saves when not inmanualmode valuesetter vssave()/trigger: assigningvalue(non-manual) persists the assigned argument (write-through).save()andtrigger, by contrast, persist the currentvalue— which a priorload()or a cross-tabstorageevent may have updated. This meanstrigger/save()can write back a value that arrived from another tab.valueinmanualmode: thevaluesetter stages the value (no storage write) instead of persisting it.el.value = xupdates the readable value (el.value === x) but does not touch storage; the actual write happens only viasave()/trigger. This is what makes thevalue: …+trigger: …binding pair work — the bound value is staged, then committed on trigger.- No echo guard on the non-manual
valuepath: only the staging path (the Corevaluesetter, used inmanualmode) skips a same-valuevalue-changedre-dispatch. The main non-manual path (valuesetter →save()) is deliberately write-through: every assignment persists and re-emitsvalue-changed, even when the assigned value equals the current one. This is intentional — the write-through contract above must hold, and same-tabstorageevents do not re-fire, so there is no feedback loop. In adata-wcs="value: x"two-way binding the echoedvalue-changedis harmless:@wcstack/statededups the round-trip on its side. savecommand arity: the headless Core takessave(value), while the Shell exposessave()(persists the current value). Both appear under the samecommandsnamesave; the protocol'scommandsmetadata is descriptive and arity-less, so this is contractual, not a protocol violation.- Invalid
type: anytypeattribute other than"session"is treated as"local". An invalid value (e.g.type="foo") silently falls back tolocalrather than throwing. - Runtime
typechange: changing thetypeattribute after connection updates the Core's storage area for subsequent operations but does not re-load from the new area (onlykeychanges auto-reload in non-manual mode). Re-load explicitly withload()if you need the value from the newly selected area. errorshape: on a storage failure,erroris set to aWcsStorageError({ operation, message }) identifying which call failed (load/save/remove, ortypefor an invalid headlesstypeassignment). Operations are never-throw: calling one with no key does not throw — it is surfaced onerroras{ operation, message: "key is required." }(and dispatched aswcs-storage:error). In practiceerroris therefore always either aWcsStorageErrorornull; the widerWcsStorageError | Error | nulltype is kept for forward compatibility and consistency with sibling packages.- JSON auto-serialization handles objects, arrays, and primitives transparently
- Saving
null/undefinedremoves the key from storage - Cross-tab sync via
storageevent works only with localStorage. The Shell binds the watcher to its currentkey/typeon connect (and re-binds on re-attach), so cross-tab sync works even inmanualmode where no auto-load runs. Changing thekeyattribute after connection always re-syncs the Core key, so cross-tab sync follows the new key even inmanualmode or when the key is cleared. A successful cross-tab update also clears any staleerror(just likeload()/save()/remove()do at the start of a successful operation), so a fresh value never coexists with a leftover error from an earlier failure. manualis useful when you want explicit control over save timing
License
MIT
