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/store → select
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();
html
<script src="https://cdn.jsdelivr.net/npm/@sometic/store@1.1.3/dist/cdn/sometic-store.iife.js"></script>
<script>
    const store = SometicStore.createStore({ count: 0 });
    store.update((s) => ({ count: s.count + 1 }));
</script>
html
<script type="module">
    import { createStore } from "https://cdn.jsdelivr.net/npm/@sometic/store@1.1.3/dist/cdn/sometic-store.esm.js";

    const store = createStore({ count: 0 });
    store.update((s) => ({ count: s.count + 1 }));
</script>

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.