v0.1.0Zero dependencies · 4.7 kB gzip with every layer

State that survives the page reload and

validates every read and write keeps bytes it cannot understand migrates old schema versions stays in sync across tabs binds to defuss-shadcn components works in Node.js as well

defuss-store is a small, synchronous observable store with path writes, selectors and computed values. Persist what must survive a reload to localStorage or sessionStorage - validated, versioned, and with unreadable bytes left untouched for you to recover - and bind it to any defuss-shadcn component. Plain JavaScript or TypeScript, with or without defuss.

Built by Aron Homberg (kyr0) (opens in a new tab) – Open Source (MIT (opens in a new tab))

Synchronous, in order

set() has committed state when it returns. Listeners run first in, first out; one that throws does not stop the others, and the errors arrive together as one AggregateError.

Bad bytes stay put

Unparsable JSON, a value your validator rejects or a future schema version pauses writes and keeps the stored bytes. You can recover explicitly: rehydrate(), flush() or clear().

Validated and versioned

A validator is mandatory on every read and write. A schema version plus migrate() upgrades old data, and the migrated value is validated again.

In sync across tabs

sync: true follows native storage events: another tab's write arrives without echoing back. Whole-value, last write wins.

The backbone of every JS-based defuss-shadcn component

defuss-shadcn keeps each component's state in a defuss-store store, el.store is accessible from the DOM element. defuss-store's API is exposed via df$.store and bindState() can be used to tie your own stores to any State API.

Small, and safe on servers

The core is 1.9 kB gzip, every layer 4.7 kB, with zero dependencies. Importing it touches no window, document or storage.

Set state.

Observe it.

Persist it.

Live demo

🧪 Three app demos

A form, a chat and sticky notes, each on one store persisted to localStorage. Type anything, then press Reload demo: it all comes back, the open tab included. The panel on the right logs every change through store.subscribe(), and Get state / Set state drive each store through its API. Open it full screen in a second tab and type there: this frame follows.

Same origin as this page: the frame, a full-screen tab and this page's dark-mode switch share one localStorage.

The code that runs above

Excerpts of the demo's own files - scripts/verify.ts fails the release if a line here no longer exists in its source. Every store is createStore() plus one attachPersistence(); the views only subscribe and write.

import { attachPersistence, createMemoryStorage, createWebStorage } from './defuss-store/browser.js';let memoryOnly = false;export const storage = createWebStorage('local', {  fallback: createMemoryStorage(),  onUnavailable: () => { memoryOnly = true; },});// …export function persist(store, key, validate, onError) {  return attachPersistence(store, { key, storage, version: 1, validate, sync: !memoryOnly, onError });}
export function formDemo(panel) {  const store = createStore(defaults.form);  const form = panel.querySelector('form');  // …  // Store → form. Whoever wrote the value - the keyboard, "Set state", a reload or another tab - the fields follow.  store.subscribe((value) => {    for (const [name, next] of Object.entries(value)) {      const field = form.elements.namedItem(name);      if (field instanceof RadioNodeList) field.value = next;      else if (field.type === 'checkbox') field.checked = next;      else if (field.value !== next) field.value = next;    }  }, { immediate: true });  // …  // Form → store: one path write per edit, so the devtools log `name: "Ad" → "Ada"`, not a root replacement.  form.addEventListener('input', (event) => {    const field = event.target;    if (field.name) store.set(field.name, field.type === 'checkbox' ? field.checked : field.value);  });
  // Store → view: the draft and the transcript are two selections, so typing never re-renders the thread.  store.subscribe((chat) => chat.draft, (text) => {    if (draft.value !== text) draft.value = text;    count.textContent = `${text.length} / ${LIMITS.draft}`;    sendButton.disabled = text.trim() === '';  }, { immediate: true });  // …  // View → store.  draft.addEventListener('input', () => store.set('draft', draft.value));  // …  composer.addEventListener('submit', (event) => {    event.preventDefault();    const text = store.value.draft.trim();    if (!text) return;    store.update((chat) => send(chat, { id: newId('m'), from: 'you', text, at: new Date().toISOString() }));    reply();  });
  // Board → store: paths, so the devtools show which note changed.  add.addEventListener('click', () => {    const id = newId('n');    store.update((value) => addNote(value, id));    document.getElementById(`note-${id}-text`)?.focus();  });  list.addEventListener('input', (event) => {    const el = event.target.closest('.ds-note');    const path = el && notePath(store.value, el.dataset.id);    if (!path) return;    store.set(`${path}.text`, event.target.value);    el.querySelector('.ds-note-status').textContent = `${persistent() ? 'Saved to localStorage' : 'Kept in memory'} · ${clock()}`;  });  // …  list.addEventListener('click', (event) => {    const button = event.target.closest('[data-delete-note]');    const path = button && notePath(store.value, button.closest('.ds-note').dataset.id);    if (!path) return;    store.remove(path);    add.focus();  });
  /** Subscribe to a store. Call before attaching persistence, so the restore after a reload shows up as a change. */  function watch(name, store, { key = null, origin = 'this page' } = {}) {    const entry = { store, key, persistence: null, origin };    watched.set(name, entry);    store.subscribe((next, previous, changedPath) => {      const change = describeChange(next, previous, changedPath);      changes++;      total.replaceChildren(...withCode(`${changes} ${changes === 1 ? 'change' : 'changes'} via store.subscribe()`));      line(name, `${change.path}: ${change.before} → ${change.after}`, tones[change.kind]);      schedule();    });    return entry;  }
for (const demo of Object.values(demos)) {  const entry = devtools.watch(demo.name, demo.store, { key: demo.key });  const saved = $(`panel-${demo.name}`).querySelector('[data-saved]');  demo.store.subscribe(() => { saved.textContent = `${persistent() ? 'Saved' : 'In memory'} · ${clock()}`; });  demo.persistence = persist(demo.store, demo.key, demo.validate, (failure) => {    saved.textContent = `Not saved: ${failure.operation} failed`;    devtools.note(demo.name, `persistence ${failure.operation} failed: ${failure.error?.message ?? failure.error}`, 'destructive');  });  entry.persistence = demo.persistence;// …// Which tab is open is state too: a persisted ui store, bound to the tablist's State API with bindState().const ui = createStore(defaults.ui);devtools.watch('ui', ui, { key: KEYS.ui }).persistence = persist(ui, KEYS.ui, isUi, (failure) => {  devtools.note('ui', `persistence ${failure.operation} failed: ${failure.error?.message ?? failure.error}`, 'destructive');});const tablist = $('demo-tabs');bindState(tablist, ui, {  select: (value) => value.tab,  toState: (tab) => ({ name: 'active', config: { id: `tab-${tab}` } }),  fromState: (state, current) => {    const tab = String(state.config.id ?? '').replace(/^tab-/, '');    return TABS.includes(tab) ? { ...current, tab } : current;  },});ui.subscribe((value) => value.tab, (tab) => devtools.focus(tab), { immediate: true });// …// defuss-shadcn components keep their own state in defuss-store stores, exposed as el.store: subscribe like any store.devtools.watch('tabs.store', tablist.store, { origin: 'defuss-shadcn tabs (el.store)' });devtools.watch('layout.store', $('layout').store, { origin: 'defuss-shadcn border layout (el.store)', key: 'data-save="defuss-store-docs:layout"' });
How it works

💡 One store commit() - consistency follows

A write compares, installs the new snapshot and queues one transition. Listeners drain in order; persistence is one of them, so the value is validated and stored before set() returns. Other tabs read the new bytes from their own storage event.

set(path, value)
Equal?
No changeno snapshot, no listener
CommitNew snapshottouched ancestors copied
NotifyListeners, FIFO(next, previous, changedPath)
validate()
onErrormemory kept, dirty, retried
localStorageversioned envelope
sync: trueOther tabsstorage event → reread
  1. yes
  2. no
  3. persistence
  4. rejected / quota
  5. valid

What a reload finds

  • restored

    Valid bytes of the current version are in memory when attachPersistence() returns, and writes resume.

    phase: 'active'
  • migrated

    Data of an older version goes through migrate(old, fromVersion), is validated, and stays dirty until the next save.

    dirty: true
  • missing

    Nothing is stored: memory keeps its defaults and nothing is written until something changes.

    hasStoredValue: false
  • paused

    Bad JSON, a rejected value or a future version: the bytes stay untouched and automatic writes pause.

    phase: 'paused', lastError

Recovery is explicit

  1. rehydrate()

    Read the key again after fixing it; saved state wins, a missing key keeps memory.

  2. flush()

    Validate and save what is in memory, replacing the bad bytes on purpose.

  3. clear()

    Remove only this key. Memory keeps its state; the next change writes again.

With or without defuss

🔌 Plain JavaScript first, defuss when you want it

The store library doesn't require any specific framework to be integrated with. In a web app that uses defuss-shadcn, it is already loaded, as df$.store is populated and as every JS component's API exposes el.store, setState() and getState() as well as render() to re-render after state change.

Without defuss

Any app, any bundler or none.

  • npm install defuss-store, then import defuss-store, /storage, /persist or /bind - each layer alone.
  • Or one file in the browser: dist/browser.js bundles every layer and imports nothing.
  • Node 22.12+ for SSR; declarations for TypeScript; ESM only.
  • Use the raw API directly.

With defuss

defuss-shadcn pages and defuss apps.

  • defuss-shadcn's core.js ships it as df$.store.create, computed and persisted.
  • Every component's state is a store: el.store.subscribe() sees clicks and Escape, el.store.set() drives it.
  • bindState() ties your own (persisted) store to any component's State API.
  • In defuss JSX, subscribe in onMount and unsubscribe in onUnmount.
import { createStore } from 'defuss-store';import { createMemoryStorage, createWebStorage } from 'defuss-store/storage';import { attachPersistence } from 'defuss-store/persist';type Prefs = { theme: 'light' | 'dark'; pageSize: number };const isPrefs = (value: unknown): value is Prefs => {  const v = value as Partial<Prefs> | null;  return v?.theme === 'light' || v?.theme === 'dark' ? Number.isInteger(v.pageSize) : false;};const prefs = createStore<Prefs>({ theme: 'light', pageSize: 20 });attachPersistence(prefs, {  key: 'my-app:prefs', version: 1, validate: isPrefs,  storage: createWebStorage('local', { fallback: createMemoryStorage() }),});prefs.subscribe((p) => p.pageSize, (size) => console.log('page size', size));prefs.set('pageSize', 50); // committed, logged and stored before this line returns
<button>Clicked <output>0</output> times</button><script type="module">  import { attachPersistence, createStore, createWebStorage }    from 'https://cdn.jsdelivr.net/npm/defuss-store@0.1.0/dist/browser.min.js';  const clicks = createStore(0);  attachPersistence(clicks, {    key: 'clicks', version: 1, storage: createWebStorage('local'),    validate: (value) => Number.isSafeInteger(value),  });  clicks.subscribe((n) => { document.querySelector('output').value = n; }, { immediate: true });  document.querySelector('button').onclick = () => clicks.update((n) => n + 1);</script>
// defuss-shadcn's core.js (or all.js) ships defuss-store as df$.store - nothing to import.const count = df$.store.create(0);count.subscribe((n) => console.log('count', n));count.set(1);                       // → count 1// a value kept in localStorage: validated against the initial shape, in memory when storage is blockedconst prefs = df$.store.persisted('my-app:prefs', { compact: false });prefs.subscribe((p) => df$('html').toggleClass('compact', p.compact));prefs.set('compact', true);         // a path write; stored at once
// Every defuss-shadcn component keeps its state in a defuss-store store: { name, config }.const dialog = df$('#confirm').get(0);// Subscribers see every change, also the ones without setState: a trigger, Escape, the backdrop.dialog.store.subscribe(({ name }) => console.log('dialog is', name));// Writing the store drives the component exactly like api.setState('open').dialog.store.set({ name: 'open', config: {} });
import { attachPersistence, bindState, createStore, createWebStorage } from 'defuss-store/browser';const ui = createStore({ tab: 'account' });attachPersistence(ui, {  key: 'my-app:ui', version: 1, storage: createWebStorage('local'),  validate: (value) => typeof value?.tab === 'string',});// Attach persistence first: the restored tab then initializes the component.bindState(document.querySelector('#settings-tabs'), ui, {  select: (value) => value.tab,  toState: (tab) => ({ name: 'active', config: { id: tab } }),  fromState: (state, current) => ({ ...current, tab: String(state.config.id) }),});ui.set('tab', 'billing'); // clicks, arrow keys and this call all agree - and survive a reload
import { createRef } from 'defuss';// defuss has an older createStore of its own: import this one by package name.import { createStore } from 'defuss-store';const cart = createStore({ count: 0 });export function CartCount() {  const out = createRef<HTMLOutputElement>();  let off = () => {};  return (    <output      ref={out}      onMount={() => {        off = cart.subscribe((c) => c.count, (n) => { out.current.textContent = String(n); }, { immediate: true });      }}      onUnmount={() => off()}    />  );}
API

📚 Small API surface

Five exported package entrypoints, each importable on it's own. The source of truth is the README (opens in a new tab) and src/types.ts (opens in a new tab).

createStore(initial, { equals? }) returns a writable store. Equality is Object.is by default: an equal write keeps the old reference and notifies nobody.

MemberBehavior
.value, .get(), .getRaw()The current snapshot, by reference. Treat it as immutable: mutating it in place is not observed.
.get('field')A known top-level key keeps its TypeScript type.
.get('items[0].name')A dynamic path returns unknown; own properties only.
.set(next), .setRaw(next)Replace the root, including null or undefined when T allows it.
.set(path, next)Copy the touched ancestors, keep every other reference. Dot or bracket paths: items.0.name, items[0].name.
.remove(path), .set(path, undefined)Delete an object property or splice an array index.
.update(fn)Replace with fn(current), synchronously. A throwing updater changes nothing.
.reset(), .reset(next)Restore the initial reference (not a deep clone), or replace with the explicit argument.
.subscribe(listener, { immediate? })Receives (next, previous, changedPath); returns an idempotent unsubscribe.
.subscribe(selector, listener, { equals?, immediate? })Notifies only when the selected result changes.
.onDestroy(cleanup), .destroy()Register cleanup; destroy idempotently. The last snapshot stays readable.
Reentrant writes and listener errors

A write inside a listener commits at once; its notification is queued and the queue drains first in, first out. While an earlier callback runs, .value can already hold a later value - use the callback's next argument. Removed listeners are skipped; new ones see only later commits. Listener errors do not stop other listeners or persistence: an AggregateError is thrown after the queue drains. There is no rollback or transaction API.

Paths

Paths work on plain objects and arrays with nonnegative indices. Empty writes, malformed paths, inherited traversal and the segments __proto__, prototype and constructor are rejected before anything changes. Dynamic path setters are not compile-time schema validation; prefer a typed update() for that.

Small, and measured

Sizes are make metrics output for the v0.1.0 build: each entry bundled alone, minified and gzipped. Coverage is make coverage, rounded down. scripts/verify.ts fails the release when a number here differs from dist/stats.json or the coverage report.

4,702 B gzipevery layer: store, storage, persistence and bindings
1,853 B gzipthe core alone: stores, paths, selectors, computed
0runtime dependencies, checked by the release gate
98 % linesunit-test line coverage of core, storage and persistence (95 % of branches); bindings are tested in Chromium
make metrics
"index":   … "bundledGzipBytes": 1853
"persist": … "bundledGzipBytes": 1972
"browser": … "bundledGzipBytes": 4702
npm pkg get dependencies
{}
Measured by scripts/stats.ts with esbuild and zlib gzip; abbreviated to the gzip sizes.
Benchmarks

📈 How it scales, measured

One operation, timed while its input grows: deeper paths, longer lists, more subscribers. A path is parsed and walked segment by segment, a write copies each object and array on its path, and a commit visits every subscriber. So each cost grows with what it touches and with nothing else; the charts show by how much. Persistence encodes the whole stored value on every write, so its cost follows the stored size: keep large, fast-changing lists out of persisted stores, or split them.

Loading the measurements…

Deeper paths

    store.get(path) and store.set(path, value) on child.child.….value; both axes logarithmic.

    Longer lists

      Reading and writing the last item of { items: [{ id, text }] }, without and with persistence to memory storage.

      More subscribers

        One set() with plain listeners (all called), and with selectors where one selection changes (all evaluated, one called).
        Every measurement as a table
        AxisOperationSizeMedianMiddle half of the timings

        Run it yourself: make bench builds, runs the fixed-size benchmarks and writes this scaling run to output/bench-scaling.json. The source is scripts/bench-scaling.ts (opens in a new tab); the numbers describe the machine above, not yours.

        Install

        🧑‍💻 Add it to your app

        From npm, or as the one built file. Then persist a store, or hand one prompt to your coding agent.

        From npm

        ESM with TypeScript declarations, for Node 22.12+ and modern browsers. Zero dependencies.

        npm install defuss-store
        bun add defuss-store
        pnpm add defuss-store

        One file

        dist/browser.js bundles every layer and imports nothing, so it runs as a plain browser module. This site serves a byte-for-byte copy of it.

        defuss-store.js 22,421 B · ESM
        jsDelivr
        https://cdn.jsdelivr.net/npm/defuss-store@0.1.0/dist/browser.min.js

        Persist what matters

        A store per concern; persistence only where a reload must not lose anything. The validator is yours - it decides what may enter storage and what may come back out.

        import { createStore } from 'defuss-store';import { createMemoryStorage, createWebStorage } from 'defuss-store/storage';import { attachPersistence } from 'defuss-store/persist';// v1 stored { dark: boolean }; v2 stores { theme: 'light' | 'dark' | 'system' }.type Settings = { theme: 'light' | 'dark' | 'system' };const isSettings = (v: unknown): v is Settings =>  ['light', 'dark', 'system'].includes((v as Partial<Settings> | null)?.theme as string);const settings = createStore<Settings>({ theme: 'system' });const persistence = attachPersistence(settings, {  key: 'my-app:settings', version: 2, validate: isSettings,  storage: createWebStorage('local', { fallback: createMemoryStorage() }),  migrate: (old, fromVersion) => fromVersion === 1 && (old as { dark?: unknown }).dark === true    ? { theme: 'dark' } : { theme: 'system' },  sync: true,  onError: (failure) => console.warn(failure.operation, failure.error),});persistence.status(); // { phase: 'active', dirty: true, hasStoredValue: true } after a v1 → v2 migration
        import { computed, createStore, jsonEquals } from 'defuss-store';const cart = createStore({ items: [{ sku: 'a', qty: 1, price: 12 }], coupon: '' });const total = computed(cart, (c) => c.items.reduce((sum, i) => sum + i.qty * i.price, 0));// A selector listener runs only when its selection changes.cart.subscribe((c) => c.items.length, (count) => console.log('items', count));// Object selections need an explicit equality.cart.subscribe((c) => ({ coupon: c.coupon }), (s) => console.log(s.coupon), { equals: jsonEquals });cart.set('items[0].qty', 3);  // copies items and items[0]; coupon keeps its referencetotal.value;                    // 36cart.remove('items[0]');        // splices the array → 'items' 0
        import { computed, createStore } from 'defuss-store';// Importing defuss-store touches no window, document or storage, so server code can share modules with the client.export function renderGreeting(user: { name: string; unread: number }) {  // One store per request: never export a mutable user store from a module.  const session = createStore(user);  const label = computed(session, (s) => `${s.name}: ${s.unread} unread`);  const text = label.value;  session.destroy(); // also destroys the computed store  return text;}

        Vibe coding

        Paste this prompt into your coding agent. It links the repository, so the agent works from the current README and ARCH.md.

        Integrate defuss-store into this app: https://github.com/kyr0/defuss-store
        Read its README.md and ARCH.md first. Install it with npm install defuss-store (ESM, zero dependencies, Node 22.12+).
        Keep one store per concern with createStore(). Write with set(path, value), update(fn) or remove(path); never mutate a snapshot in place.
        Persist only what must survive a reload: attachPersistence() with a key, a schema version, a real validate() for the stored shape and createWebStorage('local', { fallback: createMemoryStorage() }); add sync: true where tabs must agree.
        Bind defuss-shadcn components with bindState() or their el.store. On the server, create stores per request.
        ⏎ send

        Give your agent the defuss-vae skills too: it then plans, tests and reviews against a verifier instead of declaring itself done. In Claude Code, the plugin adds the commit and Stop-hook gate. Needs python3 ≥ 3.9, git and make.

        npx skills add kyr0/defuss-vae --skill '*'
        claude plugin marketplace add kyr0/defuss-vaeclaude plugin install defuss-vae@defuss-vae
        Portrait of Aron Homberg

        Stressed with inconsistent results and tiring reviews?

        Build more consistent agents with defuss-vae (opens in a new tab), Aron Homberg’s Verified Agentic Engineering (opens in a new tab) method. Aron is a freelance AI researcher, O’Reilly author (2011), conference speaker, and longtime mentor to software engineering teams.

        Talk to Aron on LinkedIn (opens in a new tab)

        ...if you're looking for a remote training or mentoring session for you or your engineering team.

        Keep the state your users typed

        Synchronous stores, validated persistence that never overwrites what it cannot read, and bindings for every defuss-shadcn component - without a dependency.

        MIT licensed · v0.1.0 · zero runtime dependencies