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.4 | 0.5 |
|---|---|
store.state.count | store.count |
store.state.count = 5 (or via mutate) | store.count = 5 |
store.get.total.value | store.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.4 | 0.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 update | patch({ 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.4 | 0.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 only | callbacks receive (value, prev) |
| per-key deep watchers ran eagerly from store creation, whether or not anyone subscribed | lazy: 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.x → x.onDispatching
Event subscribers moved from group objects keyed by action name onto the action function itself.
| 0.4 | 0.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 behavior | 0.5 behavior |
|---|---|
async onDispatched published the pending promise as the result | publishes the resolved value, after the promise resolves |
async rejections never reached onFailure (no rejection handler) and surfaced as unhandled rejections | rejections 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.4 | 0.5 |
|---|---|
sync throw → logged, call returns undefined | sync throw → fires onFailure, then re-throws to the caller |
async rejection → unhandled rejection, onFailure missed | call returns the original promise — await observes the rejection; onFailure fires; un-awaited calls stay quiet |
Code that relied on undefined returns must change. This 0.4 pattern:
// 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):
// 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.name → store.$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.4 | 0.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 store | 0.4 actual behavior | 0.5 default ('scoped') behavior | To keep the old feel |
|---|---|---|---|
| parameterless, no lifetime arg | one global instance per realm, never disposed | one shared instance per app context, honors defineProvide overrides, disposed on app.unmount() | usually fine as-is; pass 'singleton' for explicit app-global state |
| parameterized setup | fresh instance per call | shared instance; arguments honored at first creation only | pass '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.5 | Replacement |
|---|---|
StoreEvents | gone — $events typing derives from PublicState<TReturn> automatically |
StoreReturnDefineAction | StoreActions<T> / StoreAction<F> |
IReturnSetupStore | gone — the setup returns any object; UnwrapStore<TReturn> derives the surface |
MutateFn | gone 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
// ── 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-…'
// ── 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
- Upgrade
sigx/ core packages to0.5.x, then@sigx/storeto0.5.0. - In each setup: destructure
{ state, signals, patch }fromdefineState, replacemutate.x(...)with assignments orpatch, and return{ ...signals, ...actions, ...computeds }. - Flatten all instance access: drop
.state/.get/.actions/.value. - Rename event subscriptions:
onMutatedX→$events.x(now(value, prev), no replay),actions.onDispatching.x→x.onDispatching. - Find call sites that depended on swallowed errors (
undefinedreturns, missingcatches) and handle the exception/rejection. - Replace
store.namewithstore.$idandstore.dispose()withstore.$dispose(). - Review lifetimes: add
'singleton'where you depended on de-facto global state,'transient'where parameterized stores must stay per-call. - Delete imports of
StoreEvents,StoreReturnDefineAction,IReturnSetupStore,MutateFn,InstanceLifetimes. - While you're in there: adopt
pending,persist, andstoreToSignalswhere they delete code.
