@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 { username: "" };
</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
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 { sharedCounter: 0 };
</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.
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 |
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";
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
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. If the underlyingsave()throws (e.g.keyis unset),triggeris still reset tofalseand the completion event still fires, 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 operation (load/save/remove) failed.key is required(calling an operation with no key) is thrown synchronously, not surfaced viaerror. 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