Store

@sometic/store is a minimal external store designed for useSyncExternalStore, signals, and Vanilla subscriptions. Persistence and cross-tab sync live on intentional subpaths so the core stays small.

Overview

ModuleImport
Basic store@sometic/store
Selector helper@sometic/storeselect
Persistent store@sometic/store/persistent
Cross-tab store@sometic/store/cross-tab
Immer adapter@sometic/store-immer (optional peer)

When to use

Shared application or engine state that must work across frameworks without pulling Redux, Zustand, or Signals into every adapter.

When not to use

  • Component-local controlled props → @sometic/core/controllable-state
  • Fire-and-forget pub/sub → @sometic/events
  • Deep nested mutable trees as the default → only then consider Immer

Installation

pnpm
pnpm add @sometic/store
npm
npm install @sometic/store
yarn
yarn add @sometic/store
bun
bun add @sometic/store

Peer-free core. Depends on @sometic/core for disposable, error, and JSON helpers used by persistence.

Usage

Create, get, set, update, subscribe

js
import { createStore } from "@sometic/store";

const store = createStore({ count: 0 });

store.get();
store.set({ count: 1 });
store.update((state) => ({ count: state.count + 1 }));

const unsubscribe = store.subscribe((state, previous) => {
    console.log(previous.count, "→", state.count);
});

unsubscribe();
store.dispose();
ts
import { createStore } from "@sometic/store";

type CounterState = { count: number };

const store = createStore<CounterState>({ count: 0 });

store.get(); // { count: 0 }

store.set({ count: 1 });

store.update((state) => ({ count: state.count + 1 }));

const unsubscribe = store.subscribe((state, previous) => {
    console.log(previous.count, "→", state.count);
});

unsubscribe();
store.dispose();
js
import { createStore } from "@sometic/store";

const store = createStore({ count: 0 });
const unsubscribe = store.subscribe((state) => {
    console.log(state.count);
});
store.update((state) => ({ count: state.count + 1 }));
unsubscribe();
store.dispose();

Batch

batch collapses nested writes into one notification after the callback finishes. Nested batch calls nest correctly.

ts
store.batch(() => {
    store.set({ count: 1 });
    store.update((state) => ({ count: state.count + 1 }));
});
// listeners run once with the final state

Equality

By default, Object.is decides whether set / update commit. Pass equalityFn when you need structural or shallow equality for whole-state replacement.

ts
import { createStore } from "@sometic/store";
import { shallowEqual } from "@sometic/core/utils";

const store = createStore({ count: 0, label: "n" }, { equalityFn: shallowEqual });

Dispose

After dispose(), get / set / update / batch / subscribe throw. Disposing twice is a no-op. Always dispose stores you create in tests, SSR request scopes, or short-lived engines.

Selectors (select)

select(store, selector, equalityFn?) returns a slice view: get and subscribe that notify only when the selected value changes (default Object.is on the slice).

ts
import { createStore, select } from "@sometic/store";

const store = createStore({ count: 0, name: "Ada" });
const countSlice = select(store, (state) => state.count);

countSlice.get(); // 0

countSlice.subscribe((count, previous) => {
    console.log(previous, "→", count);
});

store.update((state) => ({ ...state, name: "Grace" }));
// countSlice does not notify

store.update((state) => ({ ...state, count: 1 }));
// countSlice notifies with 1

Use a custom equality function for selected objects or arrays:

ts
import { shallowEqual } from "@sometic/core/utils";

const profile = select(store, (state) => ({ name: state.name, count: state.count }), shallowEqual);

Persistent store

Import from @sometic/store/persistent.

API surface

ExportRole
createPersistentStoreStore + hydrate + auto-write
createMemoryStorageIn-memory StorageAdapter (tests / SSR default)
createWebStorageAdapterLazy localStorage / sessionStorage adapter

Options

OptionTypeDefaultDescription
keystringrequiredStorage key
storageStorageAdapterrequiredRead/write/remove adapter
versionnumber1Envelope version for migrations
migrationsPersistMigration[][]Ordered upgrades from older versions
serialize / deserializefunctionsJSON envelopeCustom codecs
equalityFnStoreEqualityFnObject.isPassed to inner store
onPersistError(error) => voidnoneQuota, corrupt payload, migration gaps
syncInitialbooleantrueWrite initial state when key is missing

Extra methods on the returned store: hydrated (Promise), persistNow(), clearPersisted().

Example

ts
import { createPersistentStore, createWebStorageAdapter } from "@sometic/store/persistent";

type Prefs = { locale: string; density: "comfortable" | "compact" };

const prefs = createPersistentStore<Prefs>(
    { locale: "en", density: "comfortable" },
    {
        key: "app-prefs",
        storage: createWebStorageAdapter("localStorage"),
        version: 2,
        migrations: [
            {
                version: 2,
                migrate(previous) {
                    const legacy = previous as { locale?: string };
                    return {
                        locale: legacy.locale ?? "en",
                        density: "comfortable" as const,
                    };
                },
            },
        ],
        onPersistError(error) {
            console.warn("prefs persist failed", error);
        },
    },
);

await prefs.hydrated;
prefs.update((state) => ({ ...state, density: "compact" }));
await prefs.persistNow();
js
import { createPersistentStore, createWebStorageAdapter } from "@sometic/store/persistent";

const prefs = createPersistentStore(
    { locale: "en", density: "comfortable" },
    {
        key: "app-prefs",
        storage: createWebStorageAdapter("localStorage"),
        version: 2,
        migrations: [
            {
                version: 2,
                migrate(previous) {
                    return {
                        locale: previous?.locale ?? "en",
                        density: "comfortable",
                    };
                },
            },
        ],
        onPersistError(error) {
            console.warn("prefs persist failed", error);
        },
    },
);

await prefs.hydrated;
prefs.update((state) => ({ ...state, density: "compact" }));

Writes run only after hydration completes (ready). Failed writes call onPersistError and do not crash subscribers. Web adapters resolve storage inside methods (no import-time window). Missing storage no-ops reads/writes safely.

Corrupt envelopes raise STORE_PERSIST_CORRUPT via onPersistError and leave the in-memory initial state. Migration gaps raise STORE_PERSIST_MIGRATION_GAP.

Theme preferences use this same layer. See Theme store and Theming.

Cross-tab store

Import from @sometic/store/cross-tab.

ExportRole
createCrossTabStoreLocal store + remote apply
createBroadcastChannelTransportPreferred transport
createStorageEventTransportFallback via storage events

Options

OptionDescription
keyLogical channel key (messages filtered by key)
transportOptional custom CrossTabTransport
equalityFnInner store equality
shouldAcceptDefaults to accepting messages with revision newer than local

Each instance has a unique sourceId. Own messages are ignored. Applying a remote message does not re-broadcast (avoids loops). Default transport: BroadcastChannel when available, otherwise storage-event transport. Without either, a noop transport keeps the store local-only.

ts
import { createCrossTabStore } from "@sometic/store/cross-tab";

const shared = createCrossTabStore({ draft: "" }, { key: "editor-draft" });

shared.subscribe((state) => {
    console.log("revision", shared.revision, state.draft);
});

shared.set({ draft: "hello from this tab" });

How it works

  1. Core store holds one state value, a listener Set, and a batch depth counter. Commits that fail equality are ignored. Pending previous state is captured once per notify window so listeners always see (current, previous).
  2. Notifications are synchronous. Re-entrant updates during notification re-queue and flush after the current fan-out.
  3. select wraps subscribe and compares only the selected slice, so unchanged slices skip listeners.
  4. Persistence wraps the same store: hydrate once, then subscribe and write envelopes { version, state }. hydrated settles whether read succeeds or fails.
  5. Cross-tab posts { sourceId, key, revision, state } on local change and applies remote states when shouldAccept passes.

Under the hood this is an intentional external-store contract for adapters (useSyncExternalStore, signals), not a miniature Redux.

Edge cases

CaseBehavior
Same value via Object.isNo notify
Nested batchSingle notify when outermost batch ends
Subscribe during disposeThrows; disposed stores reject new work
Persist before hydrate finishesAuto-writes skipped until ready
localStorage quota / private modeSTORE_STORAGE_* errors → onPersistError
Corrupt JSON envelopeReported; state stays at initial
Missing migration pathSTORE_PERSIST_MIGRATION_GAP
Cross-tab own messageIgnored by sourceId
No BroadcastChannelStorage-event or noop fallback
SSR / no storageWeb adapter returns null / no-ops; memory storage works everywhere

Performance notes

  • Core gzip budget: ≤ 1.5 KB. Prefer select over full-store subscriptions in UI trees.
  • Prefer immutable updates (update returning new objects) so Object.is stays cheap.
  • Batch multi-field writes to avoid N renders in adapters.
  • Persistence serializes after every committed change post-hydrate; debounce at the app layer if you write large trees frequently.
  • Cross-tab posts the whole state; keep synced slices small or provide a custom transport.

Framework pointers

React

useStore from @sometic/react/store binds with useSyncExternalStore and an optional selector:

tsx
import { useStore } from "@sometic/react/store";
import { createStore } from "@sometic/store";

const store = createStore({ count: 0 });

export function Counter() {
    const count = useStore(store, (state) => state.count);
    return (
        <button type="button" onClick={() => store.update((s) => ({ count: s.count + 1 }))}>
            {count}
        </button>
    );
}

Full adapter guide: React.

Vue

useStore from @sometic/vue/store follows the same store contract:

ts
import { useStore } from "@sometic/vue/store";

Full adapter guide: Vue.

Vanilla

Call subscribe / dispose yourself, or use DOM bind helpers from framework Wave packages where available.

API reference

createStore(initialState, options?)

Returns DisposableStore<TState>:

MemberSignature
get() => TState
set(nextState: TState) => void
update(updater: (current) => TState) => void
subscribe(listener: (state, previous) => void) => Unsubscribe
batch(run: () => void) => void
dispose() => void
disposedboolean (getter)

CreateStoreOptions: optional equalityFn.

select(store, selector, equalityFn?)

Returns { get, subscribe } for the selected slice.

Persistent types

StorageAdapter, PersistedEnvelope, PersistMigration, CreatePersistentStoreOptions, PersistentStore.

Cross-tab types

CrossTabTransport, CrossTabMessage, CreateCrossTabStoreOptions, CrossTabStore (sourceId, revision).

FAQ

Is the store SSR-safe?

Yes. Factories do not touch browser globals at import time. Web storage adapters resolve localStorage / sessionStorage inside methods and no-op when missing. Cross-tab chooses transport inside the factory.

What if localStorage throws (quota or private mode)?

Persistent store reports via onPersistError and does not crash subscribers. In-memory state remains usable.

How do I migrate persisted data?

Set version and provide ordered migrations with increasing version numbers. Each migration's migrate receives the previous payload and must produce the next shape. Gaps throw STORE_PERSIST_MIGRATION_GAP.

Does cross-tab work without BroadcastChannel?

Yes. The default falls back to createStorageEventTransport. If neither channel nor storage listeners exist, a noop transport keeps state local.

Can I use Immer?

Yes, via the optional peer package:

bash
pnpm add @sometic/store-immer immer

See Immer adapter.

Should every component use a store?

No. Prefer props and @sometic/core/controllable-state for component-local UI state. Reach for the store when multiple trees or frameworks share the same engine state.

Why not put persistence in the root entry?

Subpaths keep tree-shaking honest: apps that only need createStore do not pay for JSON envelopes or storage adapters.

How does this relate to theme?

createThemeController({ persist: true, storage, storageKey }) builds a createPersistentStore for preferences. Details: Theme store.

Are notifications async?

No. Listeners run synchronously after commit (or after the outermost batch). Schedule async work yourself inside listeners if needed.

What equality should I use?

Default Object.is with immutable updates. Use shallowEqual from @sometic/core/utils only when you intentionally replace with new objects that share shallow fields.

Comparison vs Redux / Zustand

Concern@sometic/storeReduxZustand
Role in SometicAdapter-facing external store contractApp architectureApp architecture
Middleware / DevToolsOut of scopeFirst-classAvailable
Size in every adapterTiny core + optional subpathsHeavier if pulled into coresSmall, but still an app choice
Persistence / cross-tabFirst-party subpathsEcosystemEcosystem
ImmerOptional peer adapterCommon via middlewareCommon via middleware

Why not Zustand/Redux/Jotai in core? Those libraries are excellent at the application boundary. Sometic needs a tiny shared contract so React, Vue, Vanilla, and later adapters bind the same engines without shipping one app-state library into every package.

Why Immer is optional: most stores do not need structural sharing. Keeping immer as a peer preserves the ≤ 1.5 KB core budget.

Use Redux or Zustand for large app graphs if you prefer them; bind Sometic engines with this store (or adapters' useStore) at the edges.