@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:

<script type=class="tok-string">"importmap">
{
  "imports": {
    "@zoijs/core": "https://esm.sh/@zoijs/core@1.0.0",
    "@zoijs/storage": "https://esm.sh/@zoijs/storage@0.1.0"
  }
}
</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)Create a persistent value; reads the key (JSON) or uses initialValue
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

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 or schema validation.
  • No cross-tab sync, TTL/expiration, encryption, sessionStorage, or IndexedDB — by design.