Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
[0.20.1] - 2025-12-31
Fixed
-
Fixed circular dependency warnings in async module (Metro/Expo). Extracted
isAbortableandabortableSymbolto separateabortable-guard.tsfile, and changedtoPromiseimport to source fromutils.tsdirectly instead ofasync.ts. -
Fixed Hot Module Replacement (HMR) causing “store disposed” errors with
scoped()stores. BothnormalStrategyandstrictStrategynow use deferred disposal (50ms and 100ms respectively) to allow HMR bundle loading to complete before disposal fires. Previously,normalStrategydisposed immediately on cleanup, which raced with Metro’s async module reloading.
[0.20.0] - 2025-12-30
[0.19.0] - 2025-12-30
Added
-
mixins()now accepts aStoreSpecand returns a proxy for accessing state properties and actions as mixins:const userStore = store({ state: { name: "", age: 0 }, setup: ({ state }) => ({ setName: (name: string) => { state.name = name; }, }), }); const proxy = mixins(userStore); // Use in useStore const { name, setName } = useStore(mixins({ name: proxy.name, // mixin for state.name setName: proxy.setName, // mixin for actions.setName })); // Equivalent to: // const nameMixin = (ctx) => ctx.get(userStore)[0].name; // const setNameMixin = (ctx) => ctx.get(userStore)[1].setName; -
MixinProxynow has aselect()method for selecting multiple properties/actions:const proxy = mixins(userStore); // Array syntax - use property names as keys const userMixin = proxy.select(["name", "age", "setName"]); // Returns: (ctx) => ({ name: string, age: number, setName: function }) // Object syntax - map to custom keys const userMixin = proxy.select({ userName: "name", userAge: "age", updateName: "setName" }); // Returns: (ctx) => ({ userName: string, userAge: number, updateName: function }) // Use in useStore const { name, age, setName } = useStore(userMixin);Mixins are cached when accessed, so
select()reuses cached mixins for better performance. -
mixins()now accepts aFactory(service factory) and returns a proxy for accessing service properties as mixins:const dbService = (resolver: Resolver) => ({ users: { getAll: () => [] }, posts: { getAll: () => [] }, }); const proxy = mixins(dbService); // Use in useStore const { users } = useStore(mixins({ users: proxy.users, // mixin for service.users })); // Equivalent to: // const usersMixin = (ctx) => ctx.get(dbService).users;
Changed
-
Updated React peer dependency requirement from
^18.0.0 || ^19.0.0and development dependencies to React 19. The library now fully supports React 19’s improved Suspense behavior and concurrent rendering. -
Fixed
isNetworkError()to correctly detectDOMExceptionerrors in environments whereDOMExceptiondoesn’t extendError(e.g., jsdom). -
BREAKING: Store
metaproperty now accepts either a singleMetaEntryormeta.of(...)for multiple entries (instead of raw arrays).// Single meta - no change needed meta: persist(), // Multiple metas - use meta.of() instead of array meta: meta.of(persist(), notPersisted.for("password")),
Added
-
Custom
StrictModecomponent anduseStrictMode()hook for detecting React StrictMode. UseStrictModefromstorion/reactinstead of React’s built-in one to enable proper handling of double rendering and effect calling:import { StoreProvider, StrictMode } from "storion/react"; // Use Storion's StrictMode for optimal strict mode handling createRoot(document.getElementById("root")!).render( <StrictMode> <StoreProvider container={app}> <App /> </StoreProvider> </StrictMode> ); // Detect strict mode in components import { useStrictMode } from "storion/react"; function MyComponent() { const isStrictMode = useStrictMode(); // ... } -
async.state()now accepts a function overload for capturing synchronous execution results and Suspense patterns:// With function - captures execution result const pws = async.state(() => getValue()); console.log(pws.state.status); // "fulfilled" if returned, "rejected" if threw Error // Suspense pattern - captures thrown promises const pws = async.state(() => { const cache = getFromCache(key); if (!cache) throw fetchAndCache(key); // throws promise for Suspense return cache; }); console.log(pws.state.status); // "pending" if promise thrown, "fulfilled" if cached -
meta.of()helper for type-safe arrays of metadata entries. Returns{ metas: [...] }for proper typing.import { meta } from "storion"; const userStore = store({ state: { name: "", password: "" }, meta: meta.of( persist(), notPersisted.for("password"), ), }); -
async.action()now returns asuccess(data)method for directly setting state without executing the handler. Useful for optimistic updates, websocket/push data, SSR hydration, or testing. RespectsautoCanceloption: withautoCancel: true(default), cancels any in-flight request; withautoCancel: false, lets in-flight requests complete but prevents them from overwriting the manually set state.const userQuery = async.action(focus('user'), fetchUser); // Optimistic update userQuery.success(optimisticData); // Websocket push websocket.on('user_updated', (data) => userQuery.success(data)); // SSR hydration userQuery.success(window.__DATA__.user); -
observe()wrapper for abortable functions to observe lifecycle events. TheonStartcallback can return an object with lifecycle callbacks:onAbort,onSuccess,onError,onDone.import { abortable, observe } from "storion/async"; // Simple logging const fetchUser = abortable(async (ctx, id: string) => { ... }) .use(observe((ctx, id) => { console.log(`Fetching user ${id}`); })); // Full lifecycle const fetchData = abortable(async () => { ... }) .use(observe(() => ({ onAbort: () => console.log('Aborted'), onSuccess: (data) => console.log('Success:', data), onError: (err) => console.error('Error:', err), onDone: () => console.log('Done'), }))); // Loading indicator pattern const fetchData = abortable(async () => { ... }) .use(observe(() => { showLoading(); return { onDone: hideLoading }; })); -
effect()now automatically catches thrown promises (Suspense-like behavior). Whenasync.wait()throws a promise inside an effect, the effect will automatically re-run when the promise resolves. Usesctx.nthto detect staleness - if dependencies change before the promise resolves, the refresh is skipped. On promise rejection, the error is handled viaoptions.onError(supportskeepAlive,failFast, retry config, or custom handler).effect(() => { const s = state.otherProp; const user = async.wait(state.userAsync); // throws if pending // Effect auto-catches the promise and re-runs when resolved // If otherProp changes first, the promise refresh is skipped // On rejection, error goes through handleError (respects onError option) }); -
mixins()helper function for composing multiple mixins into a single selector. Supports array syntax for merging and object syntax for key mapping. Keys ending with “Mixin” suffix are automatically stripped.import { useStore, mixins } from "storion/react"; // Object syntax - keys mapped to results, "Mixin" suffix stripped const { t, language } = useStore(mixins({ tMixin, languageMixin })); // Array syntax - merge multiple mixins const data = useStore(mixins([userMixin, { count: countMixin }]));
Changed
- BREAKING: Removed
useStore(MergeMixin)anduseStore(MixinMap)overloads. UseuseStore(mixins(...))instead:// Before useStore({ tMixin, countMixin }) useStore([userMixin, { count: countMixin }]) // After useStore(mixins({ tMixin, countMixin })) useStore(mixins([userMixin, { count: countMixin }]))
Fixed
- Store property subscriptions now correctly re-render when a property changes while its emitter temporarily has 0 listeners (e.g. around render/commit timing windows), by buffering the last change and replaying it on the next subscription.
[0.16.7] - 2024-12-27
Fixed
-
useStore()now properly re-renders when store is hydrated after initial render. Changed subscription cleanup andscoped()store disposal from microtask (Promise.resolve) to macrotask (setTimeout) because in React 19 concurrent mode, microtasks run BEFOREuseLayoutEffect, causing subscriptions/stores to be removed prematurely before the component commits. -
effect()in selector now properly re-renders component when state changes inside the effect. Previously,scheduledEffectsarray was not cleared between renders, causing effects to accumulate and state changes during effect execution to not trigger re-renders.
[0.16.6] - 2024-12-27
Fixed
useIsomorphicLayoutEffectnow properly checks foruseLayoutEffectavailability instead of usingdev()flag, improving React Native/Expo compatibility
[0.16.5] - 2024-12-27
Added
useStore()now accepts void selectors for side effects only (e.g.,trigger, effects)useStore(({ get }) => { const [, actions] = get(dataStore); trigger(actions.fetch, [id], id); // No return - just side effects });
Fixed
async.all(),async.race(),async.any()now properly throw promises for Suspense when states are pending (instead of throwingAsyncNotReadyError)AsyncNotReadyErroris now only thrown for idle states (not started yet)useStore()now properly re-renders afterhydrate()is called, fixing race condition where state changes during async hydration were missed (especially in Expo/React Native with AsyncStorage)
[0.16.4] - 2024-12-27
Changed
- Internal improvements
[0.16.3] - 2024-12-27
Fixed
- Export
persistedmeta fromstorion/persist
[0.16.2] - 2024-12-27
Changed
PersistLoadResulttype now acceptsPromiseLikeinstead ofPromisefor better flexibility
[0.16.1] - 2024-12-27
Changed
- Async combinators (
all,race,any,settled) now accept rawPromiseLikedirectly instead of requiringPromiseWithState// Before - had to wrap promises async.all([state1, async.state(fetch("/api"))]); // After - just pass promises directly async.all([state1, fetch("/api").then((r) => r.json())]);
[0.16.0] - 2024-12-27
Added
-
async.all(),async.race(),async.any(),async.settled()now support bothAsyncStateandPromiseWithState -
New array and map overloads for all combinators:
// Array form (new) const [a, b] = async.all([state1, state2]); const [key, value] = async.race([state1, state2]); // Map form (new) const { user, posts } = async.all({ user: userState, posts: postsState }); const [key, value] = async.race({ user: userState, posts: postsState }); // Rest params (backward compatible) const [a, b] = async.all(state1, state2); -
New type utilities for
PromiseWithState:InferPromiseData,MapPromiseData,PromiseSettledResult,MapPromiseSettledResult,PromiseRaceResult -
Combined type utilities for both:
AsyncOrPromise,InferData,MapData,MapRecordData,CombinedSettledResult,MapCombinedSettledResult,CombinedRaceResult
[0.15.0] - 2024-12-27
Changed
- BREAKING: Rename
tryGet(key, create)toensure(key, create)inlist()andmap()focus helpers
[0.14.4] - 2024-12-27
Changed
- BREAKING:
async.state()now returnsPromiseWithState<T>(promise with attached state) instead ofPromiseState<T> - Removed
async.withState()- useasync.state()instead which now attaches state directly to the promise
[0.14.3] - 2024-12-27
Fixed
- Fix
PromiseWithStatetype causing infinite type recursion withmap()focus helper
[0.14.2] - 2024-12-27
Added
- Export
PromiseStateandPromiseWithStatetypes fromstorion/async
[0.14.1] - 2024-12-27
Fixed
- Remove unused import in persist module
[0.14.0] - 2024-12-27
Added
-
SelectorContext.mixin()now acceptsMergeMixin(array) andMixinMap(object) syntax, matchinguseStore()patternsconst { name, count } = useStore((ctx) => { // MergeMixin array - spreads direct mixins, maps named mixins return ctx.mixin([ selectUser, // { name, email } → spread { count: selectCount }, // → { count: number } ]); }); const { userName, userAge } = useStore((ctx) => { // MixinMap object - maps keys to mixin results return ctx.mixin({ userName: selectName, userAge: selectAge, }); });
Changed
useStore(MixinMap | MergeMixin)now internally usesctx.mixin()for code reuse
[0.13.0] - 2024-12-27
Added
-
persist()middleware now supportspersistedOnlyoption for opt-in persistence modeimport { persist, persisted, notPersisted } from "storion/persist"; // Only persist stores/fields explicitly marked with persisted meta persist({ persistedOnly: true, handler: (ctx) => ({ load: () => JSON.parse(localStorage.getItem(ctx.displayName) || "null"), save: (state) => localStorage.setItem(ctx.displayName, JSON.stringify(state)), }), }); // Store-level: entire store persisted const userStore = store({ name: "user", state: { name: "", email: "" }, meta: [persisted()], }); // Field-level: only specific fields persisted const settingsStore = store({ name: "settings", state: { theme: "", fontSize: 14, cache: {} }, meta: [persisted.for(["theme", "fontSize"])], });Filtering priority:
notPersisted(top) →persistedOnly→filteroption
0.12.0 - 2024-12-27
Added
-
useStore()now accepts array and object mixin syntax for cleaner composition// Array syntax (MergeMixin) - merges direct and named mixins const result = useStore([ selectUser, // { name, email } → spread into result { count: selectCount }, // → { count: number } ]); // result: { name: string, email: string, count: number } // Object syntax (MixinMap) - maps keys to mixin results const { userName, userAge } = useStore({ userName: (ctx) => ctx.get(userStore)[0].name, userAge: (ctx) => ctx.get(userStore)[0].age, }); -
store()now acceptstoJSONoption to control serialization behaviorconst userStore = store({ name: "user", state: { name: "", password: "" }, toJSON: "normalize", // Uses normalize function for JSON.stringify });Available modes:
"state"(default),"normalize","info","id","null","undefined","empty" -
useStore.from()for creating pre-bound hooks// From store spec const useCounter = useStore.from(counterStore); const { count } = useCounter((state, actions) => ({ count: state.count })); // From selector with arguments const useUserById = useStore.from((ctx, userId: string) => { const [state] = ctx.get(userStore); return { user: state.users[userId] }; }); const { user } = useUserById("123"); -
SelectorContext.scoped()for component-local stores that auto-dispose on unmountconst { value, setValue } = useStore(({ scoped }) => { const [state, actions, instance] = scoped(formStore); return { value: state.value, setValue: actions.setValue }; }); -
async.mixin()for component-local async state (mutations, form submissions)// Define mutation - no store needed const submitForm = async.mixin(async (ctx, data: FormData) => { const res = await fetch("/api/submit", { method: "POST", body: JSON.stringify(data), signal: ctx.signal, }); return res.json(); }); // Use as mixin - state is component-local, auto-disposed const { status, submit } = useStore(({ mixin }) => { const [state, actions] = mixin(submitForm); return { status: state.status, submit: actions.dispatch }; }); -
AsyncContext.get()allows async handlers to access other stores’ state// Access other stores for cross-store mutations const checkout = async.mixin(async (ctx, paymentMethod: string) => { const [user] = ctx.get(userStore); const [cart] = ctx.get(cartStore); return fetch("/api/checkout", { body: JSON.stringify({ userId: user.id, items: cart.items }), }); }); -
MetaEntry.fieldsnow supports arrays for applying meta to multiple fields at oncemeta: [notPersisted.for(["password", "token"])]; -
MetaQuery.fields(type, predicate?)method to get field names with a specific meta typeconst sessionFields = ctx.meta.fields(sessionStore); // ['token', 'userId'] const highPriority = ctx.meta.fields(priority, (v) => v > 5); -
applyFornow supports object form to map patterns to different middlewareapplyFor({ userStore: loggingMiddleware, "auth*": [authMiddleware, securityMiddleware], "*Cache": cacheMiddleware, });
Removed
-
BREAKING:
useLocalStorehook removed - usescoped()inuseStoreselector instead// Before const [state, actions] = useLocalStore(formStore); // After const { state, actions } = useStore(({ scoped }) => { const [s, a] = scoped(formStore); return { state: s, actions: a }; }); -
BREAKING:
SelectorContext.create()removed - creates uncontrolled instances without disposal tracking. Useget()for cached services orscoped()for component-local stores instead.
Changed
-
BREAKING:
persistAPI refactored for better encapsulation (renamed frompersistMiddleware)- New
handleroption replacesload/savecallbacks persistMiddlewareis now deprecated, usepersistinstead- Handler receives
PersistContext(extendsStoreMiddlewareContextwithstoreinstance) - Handler returns
{ load, save }object (can be sync or async) onErrorsignature changed to(error, operation)where operation is"init" | "load" | "save"- Enables encapsulated async initialization (e.g., IndexedDB)
// Before (old API) persistMiddleware({ load: (ctx) => localStorage.getItem(ctx.displayName), save: (ctx, state) => localStorage.setItem(ctx.displayName, JSON.stringify(state)), }); // After (new API) persist({ handler: (ctx) => { const key = `app:${ctx.displayName}`; return { load: () => JSON.parse(localStorage.getItem(key) || "null"), save: (state) => localStorage.setItem(key, JSON.stringify(state)), }; }, }); // Async handler (IndexedDB) persist({ handler: async (ctx) => { const db = await openDB("app-db"); return { load: () => db.get("stores", ctx.displayName), save: (state) => db.put("stores", state, ctx.displayName), }; }, }); - New
0.8.0 - 2024-12-21
Added
- Persist Module (
storion/persist)persist(options)for automatic state persistencenotPersistedmeta for excluding stores or fields from persistence- Supports sync and async
load/savehandlers forceoption to override dirty state during hydration
- Meta System
meta()function for creating typed metadata buildersMetaType.for(field)andMetaType.for([fields])for field-level metaMetaQuerywith.all()and.any()query methodswithMeta(factory, entries)for attaching meta to factories- Meta available in middleware via
ctx.meta(type)
- Middleware Utilities
forStores(middleware)helper for store-only middlewareapplyFor(patterns, middleware)for conditional middlewareapplyExcept(patterns, middleware)for exclusion patterns
store.hydrate(state, { force })- force option to override dirty properties
Changed
StoreMiddlewareContextnow includesmetaproperty for querying store metadataFactoryMiddlewareContextnow includesmetaproperty for querying factory metadata
0.7.0 - 2024-12-15
Added
- DevTools Module (
storion/devtools)devtoolsMiddleware()for state inspection__revertStateand__takeSnapshotinjected actions- State history tracking with configurable
maxHistory - DevTools panel (
storion/devtools-panel)
- withStore HOC for React
- Separates data selection from rendering
- Automatic memoization
- Direct mode and HOC mode
createWithStore(useContextHook)factory for customwithStoreimplementationscreate()shorthand for single-store apps returning[instance, useHook, withStore]
Changed
- Improved TypeScript inference for store actions
0.6.0 - 2024-12-01
Added
- Async Module (
storion/async)async.fresh<T>()- throws during loading (Suspense-compatible)async.stale<T>(initialData)- returns stale data during loadingasync.wait(state)- extracts data or throws- Automatic request cancellation via
ctx.signal ctx.safe(promise)for effect-safe async operations
trigger(action, deps, ...args)for declarative data fetching in components
Changed
- Effects now require synchronous functions (use
ctx.safe()for async)
0.5.0 - 2024-11-15
Added
- Focus (Lens-like Access)
focus(path)for nested state access- Returns
[getter, setter]tuple - Type-safe path inference
- Reactive Effects
effect(fn, options)with automatic dependency trackingctx.cleanup()for teardown logicctx.refresh()for manual re-execution- Error handling strategies:
"throw","ignore","retry"
batch(fn)for batching multiple state updatesuntrack(fn)for reading state without tracking
0.4.0 - 2024-11-01
Added
- Middleware System
container({ middleware: [...] })for middleware injection- Middleware receives
MiddlewareContextwithtype,next,resolver - Discriminated union:
StoreMiddlewareContextvsFactoryMiddlewareContext
createLoggingMiddleware()built-in middlewarecreateValidationMiddleware()built-in middleware
Changed
- Container now uses middleware chain pattern
0.3.0 - 2024-10-15
Added
- Store Lifecycle
lifetime: "keepAlive"(default) - persists until container disposallifetime: "autoDispose"- disposes when no subscribersstore.dispose()methodstore.subscribe(listener)for change notifications
store.dehydrate()for serializing statestore.hydrate(state)for restoring statestore.dirtyproperty tracking modified fieldsstore.reset()to restore initial state
Changed
- Stores now track dirty state automatically
0.2.0 - 2024-10-01
Added
- Dependency Injection
container()for managing store instancesget(factory)for resolving dependencies- Services (plain factories) support
mixin(factory)for setup-time composition
StoreProviderReact componentuseContainer()hook
Changed
- Stores are now lazily instantiated via container
0.1.0 - 2024-09-15
Added
- Core Store
store(options)factory functionstate- reactive state objectactions- returned fromsetup()functionupdate(producer)for Immer-style nested updates
- React Integration
useStore(selector)hook with auto-trackinguseLocalStore(spec)for component-scoped stores
- Reactivity
- Proxy-based dependency tracking
- Fine-grained updates (only re-render on accessed properties)
pick(state, equality)for custom comparison
- Type Safety
- Full TypeScript support
- Inferred state and action types
- Equality Utilities
strictEqual(default)shallowEqualdeepEqual
Migration Guides
Migrating to 0.8.0
Meta System Changes
If you were using internal meta APIs, update to the new public API:
// Before (internal)
spec.meta; // was MetaEntry[]
// After (0.8.0)
ctx.meta(persistMeta).store; // query store-level
ctx.meta(persistMeta).fields; // query field-level
ctx.meta.all(type); // get all values
ctx.meta.any(type1, type2); // check existence
Migrating to 0.6.0
Async Effects
Effects must now be synchronous:
// Before (broken in 0.6.0)
effect(async (ctx) => {
const data = await fetchData();
state.data = data;
});
// After
effect((ctx) => {
ctx.safe(fetchData()).then((data) => {
state.data = data;
});
});