validates every read and writekeeps bytes it cannot understandmigrates old schema versionsstays in sync across tabsbinds to defuss-shadcn componentsworks 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.
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.
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.
exportfunction 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;elseif (field.type === 'checkbox') field.checked = next;elseif (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.
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
01
rehydrate()
Read the key again after fixing it; saved state wins, a missing key keeps memory.
02
flush()
Validate and save what is in memory, replacing the bad bytes on purpose.
03
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
// 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()}/> );}
createStore(initial, { equals? }) returns a writable store. Equality is Object.is by default: an equal write keeps the old reference and notifies nobody.
Member
Behavior
.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.
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.
computed(source, project, { equals? }) and computed([a, b] as const, (a, b) => result) return a read-only store.
Topic
Contract
Evaluation
The projection runs eagerly and again on every source change.
Failures
A throwing projection keeps the last result and reports with the source's errors.
Lifetime
.destroy() detaches early; destroying any source destroys the derived store too.
Several sources
Not batched and not glitch-free across sources: each source change recomputes.
jsonEquals(a, b)
Compares JSON.stringify results: key-order sensitive, and it can throw on non-JSON values.
defuss-store/storage: synchronous string backends and the strict JSON codec. Importing it acquires no browser storage.
Acquires the area on first use. A supplied fallback is used only if acquiring fails, and then for good; a quota error never switches backends.
createMemoryStorage()
A fresh, isolated map every time. Share one instance on purpose. No events, so no sync.
jsonCodec
Checks the whole envelope first. Rejects cycles, undefined, BigInt, functions, dates, maps, non-finite numbers, -0, accessors and sparse arrays instead of losing them silently.
StorageBackend
getItem, setItem, removeItem and an optional subscribe for remote invalidation - implement it for any other store.
attachPersistence(store, { key, storage, version, validate, migrate?, legacyVersion?, codec?, hydrate?, sync?, onError? }) returns a controller. One controller owns a store at a time; destroying the store disposes it.
Option / method
Contract
hydrate: 'immediate' (default)
Read and validate before automatic writes start.
hydrate: 'manual'
Writes and remote reads wait for rehydrate(), flush() or clear().
hydrate: 'skip'
Writes on, no read; nothing is written until a change or flush().
migrate(old, fromVersion)
Returns the current schema; its output is validated. Future versions are never downgraded.
sync: true
Follows the selected area's storage events for this key; rereads the current bytes and never echoes.
rehydrate()
Read again: saved state wins, a missing key keeps memory.
flush()
Validate and save memory; recovers paused persistence on purpose.
clear()
Remove only this key; the next real change may write it again.
status()
{ phase, dirty, hasStoredValue, lastError? }
destroy()
Unsubscribe without writing or deleting; never flushes implicitly.
The stored envelope
{ "format": "defuss-store", "formatVersion": 1, "version": 1, "value": … }. The schema version is a nonnegative safe integer. Raw JSON from the old defuss store is read through legacyVersion and rewritten in the envelope on the next write.
Errors and quota
rehydrate(), flush() and clear() return true on success and false on a reported failure. A write or quota error keeps the committed memory, marks it dirty and reports through onError; the next change or flush() retries. Without onError, failures go to console.error.
bindState(element, store, { select, toState, fromState, ... }) binds one store selection to a component's State API, both ways. It returns a callable disposer with pull() and push().
Option
Contract
select, toState, fromState
The slice this component owns, how it becomes a component state, and how the effective state goes back into the store.
initial
'store' (default) applies the store to the element; 'element' reads the element first.
equals
Selection equality in both directions; it is what stops pushes and observations from echoing.
signal
Aborting releases everything, like calling the disposer or destroying the store.
events, observe, sources, onError
Extra component events, the mutation observer, which change sources count, and errors of coalesced observations.
Rejected or clamped requests - a disabled tab, an out-of-range page - are normalized back into the store. API calls are observed synchronously; events and attribute changes are coalesced into a microtask. Removing the element alone does not dispose the binding.
Import
Contents
Gzip, bundled alone
defuss-store
createStore, computed, jsonEquals and the store types
1,853 B
defuss-store/storage
Web and memory storage, the strict JSON codec
1,095 B
defuss-store/persist
attachPersistence and its types
1,972 B
defuss-store/bind
bindState and the State API types
1,048 B
defuss-store/browser
Everything above in one self-contained ESM file
4,702 B
Every entry imports without browser globals, and the core, storage and persistence declarations typecheck without DOM libraries. On a server, create stores per request: never export a mutable user store from a module.
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 Bgzipevery layer: store, storage, persistence and bindings
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).
The charts did not load
ECharts could not be fetched from jsDelivr. Every measurement is in the table below.
Every measurement as a table
Axis
Operation
Size
Median
Middle 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.
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 { 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 storereturn 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
Stressed with inconsistent results and tiring reviews?
...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.