Migrating from 0.4#

@sigx/store 0.5.0 is a full redesign of the store surface: flat signal-first access, fixed action semantics, lazy events, real lifetimes, plus new persistence/plugin/typing machinery. This page maps every 0.4 construct to its 0.5 replacement, table by table. The 0.5.0 changelog is the authoritative breaking-change list; the API Reference has full signatures for everything on the right-hand side.

Requirements first#

Store 0.5.0 is built on core 0.5.0 — the release where factory lifetimes are actually implemented and Topic v2 (which the lazy events ride on) ships. The store bundles @sigx/runtime-core and @sigx/reactivity at ^0.5.0 as ordinary dependencies, but your app's sigx package must be on 0.5 as well. Upgrade core first, then the store.

Grouped access → flat surface#

In 0.4 the store instance was the raw setup return, so everything lived behind group keys: state, get, actions, events. In 0.5 the instance is an unwrap proxy over the setup return — returned key signals read/write as plain values, returned computeds read as plain read-only values, actions pass through. The groups are gone.

0.40.5
store.state.countstore.count
store.state.count = 5 (or via mutate)store.count = 5
store.get.total.valuestore.total (read-only — assignment throws)
store.actions.add(item)store.add(item)
return { state, get: { total }, actions, events }return { ...signals, ...actions, total }

The setup contract changed with it: defineState no longer hands back just state — you spread its signals into the return, and only the keys you return become public state. Unreturned keys stay private, which 0.4 had no way to express. See Defining a Store.

mutate.x(...) → direct mutation / patch#

0.4's defineState returned { state, events, mutate }, with one mutate.key(valueOrUpdater) setter per key. In 0.5 state is deeply reactive, so direct assignment does the job, and patch covers atomic multi-key updates. mutate is gone.

0.40.5
const { state, events, mutate } = defineState({...})const { state, signals, events, patch } = defineState({...})
mutate.count(5)state.count = 5 (or store.count = 5 from outside)
mutate.count(c => c + 1)state.count++
several mutate calls for one logical updatepatch({ a: 1, b: 2 }) or patch(s => { ... }) — one reactivity flush
store.$patch({...}) — the same thing from outside, over public keys

One semantic difference to note: 0.4's mutate swallowed errors from updater functions (logged via console.error). In 0.5, errors inside a patch mutator (and plain assignments) propagate to the caller.

State events: events.onMutatedX$events.x#

0.40.5
events.onMutatedCount.subscribe(value => …)events.count.subscribe((value, prev) => …) (setup side)
store.events.onMutatedCount (only if you returned events)store.$events.count (always available for every public key)
callback received the new value onlycallbacks receive (value, prev)
per-key deep watchers ran eagerly from store creation, whether or not anyone subscribedlazy: the deep watcher behind a key runs only while it has subscribers (refCount), and nothing fires at subscribe time

Neither version replays the current value on subscribe — if you need it, read store.count directly. What changed is the cost model: 0.4 paid a deep watcher per key per store unconditionally; 0.5 pays only for keys someone is actually observing. The new model is covered in Events & Observability.

Action events: actions.onDispatching.xx.onDispatching#

Event subscribers moved from group objects keyed by action name onto the action function itself.

0.40.5
store.actions.onDispatching.add.subscribe(fn)store.add.onDispatching.subscribe(fn)
store.actions.onDispatched.add.subscribe(fn)store.add.onDispatched.subscribe(fn)
store.actions.onFailure.add.subscribe(fn)store.add.onFailure.subscribe(fn)
(setup side) actions.onDispatching.add(setup side) actions.add.onDispatching
store.add.pending — new reactive in-flight flag

Two of these events also behave differently — and both 0.4 behaviors were bugs:

0.4 behavior0.5 behavior
async onDispatched published the pending promise as the resultpublishes the resolved value, after the promise resolves
async rejections never reached onFailure (no rejection handler) and surfaced as unhandled rejectionsrejections fire onFailure with (error, ...args); no unhandled-rejection noise for fire-and-forget calls

Error semantics: swallowed → re-throw / reject#

This is the change most likely to need real code edits. In 0.4, a sync action that threw was caught internally: the error was logged, onFailure published, and the call returned undefined. In 0.5, errors are the caller's to handle — never swallowed:

0.40.5
sync throw → logged, call returns undefinedsync throw → fires onFailure, then re-throws to the caller
async rejection → unhandled rejection, onFailure missedcall returns the original promiseawait observes the rejection; onFailure fires; un-awaited calls stay quiet

Code that relied on undefined returns must change. This 0.4 pattern:

TypeScript
// 0.4 — failure signaled by undefined
const result = store.actions.parseConfig(raw);
if (result === undefined) {
    showError('invalid config');
}

becomes an ordinary try/catch (or .catch for async):

TypeScript
// 0.5 — failure is an exception, like any other function
try {
    const result = store.parseConfig(raw);
} catch (err) {
    showError('invalid config');
}

Details and patterns in Actions & Async.

Identity: store.namestore.$id#

0.4 injected a name key into your setup return, holding a guid-suffixed id like 'todos_3f9c1a…'. 0.5 keeps the surface clean — meta lives behind $:

0.40.5
store.name'todos_3f9c1a2b-…'store.$id'todos#1' (logical name + instance counter)
name key injected into your returned object (when the setup didn't return one itself)nothing injected — $-meta is invisible to spread / Object.keys
store.dispose()store.$dispose()
assigning to any $-key throws

A name key you return yourself still passes through untouched, in both versions — but the auto-injected guid format is gone; anything that parsed it should read store.$id instead.

Lifetime is now real — behavior may change without code changes#

The third defineStore argument existed in 0.4 but was ignored — core's factory layer accepted the InstanceLifetimes enum and did nothing with it. What you actually got in 0.4:

  • Parameterless stores were de-facto global singletons (one instance per JS realm, via the injectable fallback — shared even across apps).
  • Parameterized setups created a new instance on every factory call (de-facto transient).

In 0.5, lifetimes are honored by core's DI layer, and the default is 'scoped'. That means upgrading can change runtime behavior even where your code compiles unchanged:

Your 0.4 store0.4 actual behavior0.5 default ('scoped') behaviorTo keep the old feel
parameterless, no lifetime argone global instance per realm, never disposedone shared instance per app context, honors defineProvide overrides, disposed on app.unmount()usually fine as-is; pass 'singleton' for explicit app-global state
parameterized setupfresh instance per callshared instance; arguments honored at first creation onlypass 'transient' for instance-per-call

The lifetime argument is now the string union 'singleton' | 'scoped' | 'transient' (Lifetime from @sigx/runtime-core) — the InstanceLifetimes enum is gone. Audit every store that passes setup parameters: under 'scoped'/'singleton', later calls ignore their arguments and return the existing instance. See Lifetimes & DI for the full model.

Removed exports#

Removed in 0.5Replacement
StoreEventsgone — $events typing derives from PublicState<TReturn> automatically
StoreReturnDefineActionStoreActions<T> / StoreAction<F>
IReturnSetupStoregone — the setup returns any object; UnwrapStore<TReturn> derives the surface
MutateFngone with mutate — assignment and Patch<TState> cover it
InstanceLifetimes (enum, from core)Lifetime string union: 'singleton' | 'scoped' | 'transient'

And one non-removal worth stating plainly: useStore was never an export of @sigx/store — the 0.4 README showed import { defineStore, useStore } from '@sigx/store' in error. In both 0.4 and 0.5 you call the factory defineStore returns directly: const store = useCounterStore();.

New in 0.5, if you're updating imports anyway: storeToSignals, onStoreCreated, the @sigx/store/persist subpath, and the supporting types (KeySignal, StateKeyEvent, Patch, PublicState, UnwrapStore, StoreMeta, StorePluginContext, …). Full inventory in the API Reference.

A complete before/after#

TSX
// ── 0.4 ────────────────────────────────────────────────
const useCounterStore = defineStore('counter', ({ defineState, defineActions }) => {
    const { state, events, mutate } = defineState({ count: 0 });

    const actions = defineActions({
        increment() { mutate.count(c => c + 1); },
    });

    return { state, actions, events };
});

const store = useCounterStore();
store.state.count;
store.actions.increment();
store.events.onMutatedCount.subscribe(value => console.log(value));
store.actions.onDispatched.increment.subscribe(() => console.log('done'));
store.name; // 'counter_3f9c1a2b-…'
TSX
// ── 0.5 ────────────────────────────────────────────────
const useCounterStore = defineStore('counter', ({ defineState, defineActions }) => {
    const { state, signals } = defineState({ count: 0 });

    const actions = defineActions({
        increment() { state.count++; },
    });

    return { ...signals, ...actions };
});

const store = useCounterStore();
store.count;
store.increment();
store.$events.count.subscribe((value, prev) => console.log(value, prev));
store.increment.onDispatched.subscribe(() => console.log('done'));
store.$id; // 'counter#1'

Migration checklist#

  1. Upgrade sigx / core packages to 0.5.x, then @sigx/store to 0.5.0.
  2. In each setup: destructure { state, signals, patch } from defineState, replace mutate.x(...) with assignments or patch, and return { ...signals, ...actions, ...computeds }.
  3. Flatten all instance access: drop .state / .get / .actions / .value.
  4. Rename event subscriptions: onMutatedX$events.x (now (value, prev), no replay), actions.onDispatching.xx.onDispatching.
  5. Find call sites that depended on swallowed errors (undefined returns, missing catches) and handle the exception/rejection.
  6. Replace store.name with store.$id and store.dispose() with store.$dispose().
  7. Review lifetimes: add 'singleton' where you depended on de-facto global state, 'transient' where parameterized stores must stay per-call.
  8. Delete imports of StoreEvents, StoreReturnDefineAction, IReturnSetupStore, MutateFn, InstanceLifetimes.
  9. While you're in there: adopt pending, persist, and storeToSignals where they delete code.