History and persistence
Three wrappers around a writable signal that give it a memory. stored persists to localStorage, tabSync mirrors across tabs, and withHistory adds undo and redo. Each returns a signal you read and write like any other.
@mmstack/primitivesnpm
#stored
stored keeps a signal in sync with localStorage (or any adapter with the same three methods). You read and write it like a normal WritableSignal; it persists every change under the given key and reloads that value on the next visit. It is SSR-safe and falls back to the initial value when nothing is stored.
import { stored } from '@mmstack/primitives';
const theme = stored<'light' | 'dark' | 'system'>('system', {
key: 'app-theme',
syncTabs: true,
});
theme.set('dark');
theme.key(); // Signal<string> of the active key
theme.clear(); // remove the entry, restore the fallback The returned signal adds two members. .clear() removes the entry and restores the fallback, and .key is a reactive signal of the active key. The key can be dynamic: point it at a signal and the stored value follows wherever the key goes. Pass syncTabs: true to keep the value in step across tabs through the browser storage event. Other tabs on the same key pick up each change.
#tabSync
tabSync mirrors a WritableSignal across browser tabs over BroadcastChannel. It is what @mmstack/resource uses internally for cache invalidation.
import { tabSync } from '@mmstack/primitives';
const cart = tabSync(signal<Item[]>([]), { id: 'shopping-cart' });
// writes in one tab surface in the others Provide an explicit id in production. The auto-generated stack-frame id is fine for prototyping but is not stable across minified builds.
#withHistory
withHistory wraps a WritableSignal (or an initial value) into one that tracks its own edits. You get .undo(), .redo(), and .clear(), plus reactive .canUndo, .canRedo, .canClear, and a .history stack. As this composes with any WritableSignal we most use it to wrap Signal Forms sources and similar.
import { withHistory } from '@mmstack/primitives';
const text = withHistory('Hello', { maxSize: 10, cleanupStrategy: 'halve' });
text.set('Hello world');
text.undo(); // back to 'Hello'
text.redo(); // forward to 'Hello world'
text.canUndo(); // Signal<boolean>maxSize bounds both the undo and redo stacks. When the bound is hit, cleanupStrategy decides how to trim: 'shift' drops the oldest entry, 'halve' drops the older half at once.
#storeHistory
withHistory snapshots a value. storeHistory does undo and redo for a store over its op-log instead, so each entry costs only the diff, not a full copy of the state. undo() applies one inverse batch, and a new edit after an undo forks the timeline, the same as any editor.
import { store, storeHistory } from '@mmstack/primitives';
const doc = store({ title: 'Draft', body: '' });
const history = storeHistory(doc);
doc.title.set('Final');
history.undo(); // title back to 'Draft'
history.canRedo(); // Signal<boolean>
// collaborative: only your own writes are undoable
storeHistory(doc, { track: syncClient }); It composes with sync. Pass a sync client's local stream as track, and only your own writes are undoable while a remote peer's change never lands on your stack. Your undo still emits a normal operation, so it propagates to the others.
#persistedStore
stored persists a single value to synchronous localStorage. persistedStore persists a whole store to an async backend (IndexedDB) and restores it on boot. It ships no IndexedDB code: you pass an AsyncStore adapter, which idb-keyval satisfies directly and a Dexie table satisfies with a few lines. Wire the backend once with providePersistedStoreOptions and override per call.
import * as idbKeyval from 'idb-keyval';
import { persistedStore, providePersistedStoreOptions } from '@mmstack/primitives';
// wire the backend once
providePersistedStoreOptions({ store: idbKeyval });
// then anywhere
const draft = persistedStore({ title: '', body: '' }, { key: 'draft' });
draft.store.title.set('Hello'); // persisted, debounced
draft.hydrated(); // Signal<boolean>, false until the snapshot loads
await draft.flush(); // force the write now
await draft.clear(); // remove the snapshot, reset to initial
// evolve the shape across releases, migrations lazy-loaded
const profile = persistedStore({ first: '', last: '' }, {
key: 'profile',
version: 2,
migrate: async (data, from) => (await import('./migrations')).run(data, from),
}); Because the backend is async, hydration cannot precede the first read: the store is live immediately with its initial value, then adopts the persisted snapshot once the backend answers, unless a write happened first. hydrated is a signal you can gate first paint on; writes are coalesced and flushed on teardown and page hide. This is local durability, not sync. For replication across tabs, a worker, or peers, compose tabSync or @mmstack/mesh over the same store.
When the persisted shape changes between releases, pass a version and a migrate hook. A snapshot written by an older version is brought forward on boot before it is adopted, then re-persisted in the new shape, so old data heals itself. Boot is already async, so migrate can be async too: lazy import the migration ladder and pay for it only when there is old data to migrate. A snapshot from a newer version than the running build is left untouched.
persistedStore is persist plus a fresh store: reach for persist(store, opt) directly to add durability to a store you already have, for instance one you also meshSync, or a worker-owned store's replica. Persistence is a reader over the op-log, so it composes with the other readers on the same store.
import { store, persist, meshSync } from '@mmstack/primitives';
// persist attaches to a store you already have; persistedStore is store() + persist()
const doc = store({ title: '', body: '' });
persist(doc, { key: 'draft', store: idbKeyval }); // durable to disk
meshSync(doc, { room: 'doc-42', writer, transport }); // and synced to peers
// two readers over one op-log: a persisted, synced store