@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:
<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 subscribingAPI#
| Member | What 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");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 or schema validation.
- No cross-tab sync, TTL/expiration, encryption,
sessionStorage, or IndexedDB — by design.