@zoijs/storage
A drop-in, persistent createState. Same get / set / peek shape — but the value is read from localStorage on creation and written back on every set.
npm install @zoijs/core @zoijs/storageOr with no install, from a CDN with an import map — exact versions, with an integrity hash for every module (regenerate the whole map to upgrade; see Installation → From a CDN):
<script type=class="tok-string">"importmap">
{
"imports": {
"@zoijs/core": "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/index.js",
"@zoijs/storage": "https://cdn.jsdelivr.net/npm/@zoijs/storage@0.2.0/src/index.js"
},
"integrity": {
"https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/core/boundary.js": "sha384-sgP+7Uk+gh1Gk39OqMxZEo9Mox6uM6upgCbk9n4HKa8L3LywliSiXjQPQrjj/OG/",
"https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/core/brand.js": "sha384-SQKAavcGif93XT58P5unWneM/VMR1rPsGM/wF63kNN4YaR2RdK2mu2IwULo9VYix",
"https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/core/each.js": "sha384-ple5ogdJi5exkzQbgaQ8jnLLOymkmnG8dG/B8L36QkDbmIOiuRL+IdNVNNLYh+3N",
"https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/core/html.js": "sha384-5sS5ABTdRpfXmhwZuIyhqcSO7fCY1VFVlYcolWRfLsRVzytc9JnUkjfX2Adfy8WI",
"https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/core/mount.js": "sha384-eAm8c9K1SY4j66NnZ4pc4exzlek7iG67di9hU5U9S3PnaZnElkqRGpzOy2z4Uz2h",
"https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/core/renderer.js": "sha384-H2K6FI3+kv2iPxyLb8V9e9Ux4X95PcPQGYu/h7Gq9rk4brE1nLHKFvI4iG5vt7Uz",
"https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/index.js": "sha384-dDKK+RVA9Pj7X7aJvv0zgepWNZQi9W20zZ1JgACZ4EbIQ9LjvLsss8gsQGiyvcRb",
"https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/prod.js": "sha384-2Wg5h5XW9gULwwZPet1azoni63tKm9iTs9FrAoLV0vGUa77h5WPgqsG/F3TFPuBy",
"https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/reactivity/computed.js": "sha384-nlAK86Jy1IR3yFSePE38l6cjZqcu5SdnYRVQxJh/+2YQL3WhTHdZ18yCKeKL4ffm",
"https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/reactivity/core.js": "sha384-dBKT0ohkuylmZHrv0HAjRtTxrMNyo6FZTk8Fqf6LH+aAi3moXikSLGdlPaeZi5IV",
"https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/reactivity/devtools.js": "sha384-KyEdZp4AcE9RdgocIKg0Q3NKTIyCUPnMi8LLPGrDDhRCJ55DA/JcqSZRFjoHHR5X",
"https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/reactivity/effect.js": "sha384-EMCI2Kpw6k0EUn5QPC6tJLoON+nUgQ7L25AXgIrWLmhMCLKJq/jP+C6Bk/P6gGDY",
"https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/reactivity/env.js": "sha384-41791FgWw6cq/pCRB6up+hZExngmUr1tAVxQG58SdvonImEcU6jK7g14jzZAPBFL",
"https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/reactivity/owner.js": "sha384-r8/NbofI3PXPfKvjxBuhWcRZGMJQn+Ldbne0ghFkjELu6SjvpaWIHnxF8FuScUAp",
"https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/reactivity/runtime.js": "sha384-5CQnBBrMFUfcq+ahlgE4DX/nG0ua+Bo0l70VtRkXVPWQyvGtXoSScup7FHsQqbA2",
"https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/reactivity/state.js": "sha384-w2HVkiWmSdb8h7qqr4OViqHJrDqlD/oInr+5Lal+lSvXWu3SPuZv35WWDPsEOgUq",
"https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/utils/dom.js": "sha384-XsdCmmxzHfpBmOdz1648tCRrAMMHbWKIzvm4Aj2VIEpWJDIIaKmihqEARvxN6d+2",
"https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/utils/security.js": "sha384-7UafFG7FtgE/ZSVl+gEOKHc7qewaF0V6hKLEqgI9yj9mGTxz62GTzkrUPfbOXOtT",
"https://cdn.jsdelivr.net/npm/@zoijs/storage@0.2.0/src/index.js": "sha384-63CSYmhoGJSEwIW4EslSZFKff3U5GqBR/qlsFkkiME9fuJjqMYC+ZfshMMF9bGX+"
}
}
</script>What it does#
Use it for the small bits of state that should survive a reload — a theme preference, a saved draft, a remembered filter, an onboarding flag, a user preference. It feels exactly like createState; the only difference is persistence.
const theme = storage("theme", "light");
theme.get(); // reactive read inside a binding
theme.set("dark"); // updates the value AND localStorage
theme.peek(); // read without subscribingAPI#
| Member | What it does |
|---|---|
storage(key, initialValue, { validate }?) | Create a persistent value; reads the key (JSON) or uses initialValue — also when validate doesn't return true |
get() | Read the current value (reactive inside a binding) |
set(value) | Update the value and write JSON to localStorage |
peek() | Read the current value without subscribing |
Validating what comes back#
localStorage is user-editable (devtools, extensions, an XSS bug elsewhere on your origin), so a restored value can be anything JSON can express. Pass validate to check it:
const theme = storage("theme", "light", {
validate: (value) => value === "light" || value === "dark",
});
// A tampered or stale stored value ("hacked", 42, {…}) falls back to "light".validate(value)gets the parsed JSON. Returntrueto accept it; anything else — or a thrown error — usesinitialValue. The stored item is left alone until your nextset().- Values you
set()come from your code and aren't validated. - Validation checks shape, not trust: never store secrets or authorization decisions in
localStorage— see Production Security.
Theme example#
import { html, mount } from "@zoijs/core";
import { storage } from "@zoijs/storage";
const theme = storage("theme", "light");
// restore the saved theme on load
document.documentElement.setAttribute("data-theme", theme.peek());
function App() {
const toggle = () => {
const next = theme.get() === "dark" ? "light" : "dark";
theme.set(next); // updates the value AND persists it
document.documentElement.setAttribute("data-theme", next);
};
return html`<button onclick=${toggle}>Theme: ${() => theme.get()}</button>`;
}
mount(App, "#app");Reload the page and the choice is still there.
Draft form example#
import { html, mount } from "@zoijs/core";
import { storage } from "@zoijs/storage";
const draft = storage("draft", "");
function App() {
// peek() sets the initial value once, so the cursor never jumps while typing
return html`
<textarea value=${draft.peek()} oninput=${(e) => draft.set(e.target.value)}></textarea>
<p>${() => draft.get().length} characters saved</p>
`;
}
mount(App, "#app");peek(), not a reactive value=${() => ...} — a reactive value re-applies on every keystroke and moves the cursor.Values must be JSON-serializable#
The value is stored with JSON.stringify and read with JSON.parse, so use plain data: strings, numbers, booleans, null, arrays, and plain objects. A value that can't be stringified still updates the reactive state in memory — it just isn't persisted (no crash).
When storage isn't available#
If localStorage is missing, blocked (some privacy modes), or throws, storage() degrades to plain in-memory state: get / set / peek keep working and stay reactive — values simply don't persist for that session. It never throws.
Common mistakes#
- Reading outside a binding. Wrap reads in an arrow to make them live:
${() => theme.get()}, not${theme.get()}. - Binding a field's
valuetoget(). Usepeek()for the initial value to avoid cursor jumps. - Expecting cross-tab sync. A
setin one tab doesn't update another open tab — by design. - Storing huge or non-JSON data. Keep it small and serializable;
localStorageholds strings and has a modest quota.
Known limitations#
- JSON-serializable values only; no custom serializers.
validateis a predicate you write — there's no schema library. - No cross-tab sync, TTL/expiration, encryption,
sessionStorage, or IndexedDB — by design.