@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/storage

Or 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 subscribing

API#

MemberWhat 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. Return true to accept it; anything else — or a thrown error — uses initialValue. The stored item is left alone until your next set().
  • 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");
Set a text field's initial value with 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 value to get(). Use peek() for the initial value to avoid cursor jumps.
  • Expecting cross-tab sync. A set in one tab doesn't update another open tab — by design.
  • Storing huge or non-JSON data. Keep it small and serializable; localStorage holds strings and has a modest quota.
Pairs naturally with the rest of the ecosystem — see the examples or the Core API.

Known limitations#

  • JSON-serializable values only; no custom serializers. validate is a predicate you write — there's no schema library.
  • No cross-tab sync, TTL/expiration, encryption, sessionStorage, or IndexedDB — by design.