@wcstack/storage
v2.2.0
Published
Declarative persistent storage component for Web Components. Framework-agnostic localStorage/sessionStorage via wc-bindable-protocol.
Maintainers
Readme
@wcstack/storage
🤖 AI coding agents: This README is a package-level reference, not the primary entry point for building a wcstack application. If you have not already done so, first read the repository README and AGENTS.md, then use the wcstack-app skill.
@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,errorInfo
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 {
// null is what an empty key loads as — seed the element's real initial
// value, not a convenient one (see "5. Load-before-bind" below)
username: null,
};
</script>
</wcs-state>
<wcs-storage key="username" data-wcs="value#init=element: 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 - Add
#init=elementto thevaluebinding so the stored value wins the initial sync — see 5. Load-before-bind for why
2. Persisting a form as one object
The most common shape: several inputs, one storage key, restored on reload. Give the state an accessor pair — a getter that assembles the object and a setter that takes it apart — and bind value#init=element to it:
<wcs-state>
<script type="module">
export default {
form: { name: "", email: "" },
// Save path. Reading each field *by path* makes the getter re-evaluate
// whenever any of them changes; the new object flows into <wcs-storage>'s
// value, which saves it (write-through).
get formSnapshot() {
return { name: this["form.name"], email: this["form.email"] };
},
// Restore path. The persisted object arrives here on load (null for an empty key).
set formSnapshot(v) {
if (v) this.form = { ...this.form, ...v };
},
};
</script>
</wcs-state>
<wcs-storage key="signup-form" type="local"
data-wcs="value#init=element: formSnapshot"></wcs-storage>
<input data-wcs="value: form.name" placeholder="Name">
<input data-wcs="value: form.email" type="email" placeholder="Email">Flow:
- On connection
<wcs-storage>loads the key;#init=elementwrites the loaded object toformSnapshot→ the setter fillsform→ the inputs show the restored values - Typing writes
form.name→formSnapshotis dirty → its binding applies the new object tovalue→ saved - The save's own
valueevent carries the same object back; the runtime recognizes the write confirmation and stops — the setter runs once, on load
Two details that are easy to get wrong:
- Read fields as
this["form.name"], notthis.form.name. The second form registers a dependency onformonly (.nameis a plain property access on the returned object), so editing a field never re-evaluates the getter — see Dependency tracking boundaries. - With an empty key the first load writes the seed object once:
nullgoes through the setter, and a write to an accessor pair always re-evaluates the getter. Nothing persisted is ever overwritten — a stored object is restored, then re-saved unchanged.
This pattern is fixed by packages/storage/__tests__/integration.accessorPairForm.test.ts against the real @wcstack/state.
Saving on demand (manual + trigger)
To save at a moment of your choosing instead of on every change, add manual and drive trigger: from a boolean. The dependency walk is parent → child only, so a settings binding does not fire when settings.theme changes; $trackDependency lists the fields to watch and trigger commits the whole object:
<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 {
// see "5. Load-before-bind" below
sharedCounter: null,
};
</script>
</wcs-state>
<wcs-storage key="shared-counter"
data-wcs="value#init=element: 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: #init=element
<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:
valueis the one bidirectional member on this element, so its default binding authority isstate. The initial state→element apply writes the state slot's seed 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 seed.
One modifier closes both. #init=element makes the element the authority for the initial sync:
<wcs-state>
<script type="module">
export default {
// An empty key loads as null, so that is the honest seed. Reads go
// through a normalizing getter instead of a convenient [] seed.
todos: null,
get list() {
return Array.isArray(this.todos) ? this.todos : [];
},
};
</script>
</wcs-state>
<wcs-storage key="todos" type="local" data-wcs="value#init=element: todos"></wcs-storage>#init=elementskips the initial state→element write (no clobber) and pulls the element's currentvalueinto the state slot (no missed load).- Authority governs the initial sync only: every later
todosassignment still flows state→element, so auto-save keeps working. - Seed the slot with the element's real initial value (
nullfor an empty key) and null-guard reads through a derived getter. A convenient[]/""seed does not survive the initial pull anyway. - Needs
enableDirectionalInitialSync, on by default since v1.21.0. If you explicitly disable it,#init=throws; seedundefinedand pull once in$connectedCallbackinstead. - 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 |
| errorInfo | WcsIoErrorInfo \| null | Serializable failure taxonomy (stable code / phase / recoverable), derived from error. Additive — the error shape is unchanged. |
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 |
The structural Core surface is normative across wcstack IO nodes (async-io-node-guidelines §3.9); to bind it into signals with no element at all, see @wcstack/signals — Binding a Core directly.
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 |
| errorInfo | WcsIoErrorInfo \| null | Failure taxonomy (code / phase / recoverable), derived from error |
| 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" },
{ name: "errorInfo", event: "wcs-storage:error-info-changed" },
],
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.errorInfotaxonomy: an additive bindable output (wcs-storage:error-info-changed) that classifies the same failure into a serializableWcsIoErrorInfowith a stablecode/phase/recoverable, without changing theerrorshape. Validation failures (invalidtype/ missingkey) areinvalid-argument(phasestart, not recoverable). A caught storage exception is phaseexecuteand classified by itsError.name:QuotaExceededError→quota-exceeded(recoverable — succeeds once space is freed),SecurityError→not-allowed(storage access denied, not recoverable), anything else →storage-error.errorInfotransitions exactly whenerrordoes (cleared tonullon any successful operation); the sharedWcsIoErrorInfotype and theWCS_STORAGE_ERROR_CODEconstants are exported.- 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
