Skip to content

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 isAbortable and abortableSymbol to separate abortable-guard.ts file, and changed toPromise import to source from utils.ts directly instead of async.ts.

  • Fixed Hot Module Replacement (HMR) causing “store disposed” errors with scoped() stores. Both normalStrategy and strictStrategy now use deferred disposal (50ms and 100ms respectively) to allow HMR bundle loading to complete before disposal fires. Previously, normalStrategy disposed 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 a StoreSpec and 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;
    
  • MixinProxy now has a select() 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 a Factory (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.0 and development dependencies to React 19. The library now fully supports React 19’s improved Suspense behavior and concurrent rendering.

  • Fixed isNetworkError() to correctly detect DOMException errors in environments where DOMException doesn’t extend Error (e.g., jsdom).

  • BREAKING: Store meta property now accepts either a single MetaEntry or meta.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 StrictMode component and useStrictMode() hook for detecting React StrictMode. Use StrictMode from storion/react instead 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 a success(data) method for directly setting state without executing the handler. Useful for optimistic updates, websocket/push data, SSR hydration, or testing. Respects autoCancel option: with autoCancel: true (default), cancels any in-flight request; with autoCancel: 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. The onStart callback 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). When async.wait() throws a promise inside an effect, the effect will automatically re-run when the promise resolves. Uses ctx.nth to detect staleness - if dependencies change before the promise resolves, the refresh is skipped. On promise rejection, the error is handled via options.onError (supports keepAlive, 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) and useStore(MixinMap) overloads. Use useStore(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 and scoped() store disposal from microtask (Promise.resolve) to macrotask (setTimeout) because in React 19 concurrent mode, microtasks run BEFORE useLayoutEffect, 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, scheduledEffects array 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

  • useIsomorphicLayoutEffect now properly checks for useLayoutEffect availability instead of using dev() 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 throwing AsyncNotReadyError)
  • AsyncNotReadyError is now only thrown for idle states (not started yet)
  • useStore() now properly re-renders after hydrate() 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 persisted meta from storion/persist

[0.16.2] - 2024-12-27

Changed

  • PersistLoadResult type now accepts PromiseLike instead of Promise for better flexibility

[0.16.1] - 2024-12-27

Changed

  • Async combinators (all, race, any, settled) now accept raw PromiseLike directly instead of requiring PromiseWithState
    // 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 both AsyncState and PromiseWithState

  • 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) to ensure(key, create) in list() and map() focus helpers

[0.14.4] - 2024-12-27

Changed

  • BREAKING: async.state() now returns PromiseWithState<T> (promise with attached state) instead of PromiseState<T>
  • Removed async.withState() - use async.state() instead which now attaches state directly to the promise

[0.14.3] - 2024-12-27

Fixed

  • Fix PromiseWithState type causing infinite type recursion with map() focus helper

[0.14.2] - 2024-12-27

Added

  • Export PromiseState and PromiseWithState types from storion/async

[0.14.1] - 2024-12-27

Fixed

  • Remove unused import in persist module

[0.14.0] - 2024-12-27

Added

  • SelectorContext.mixin() now accepts MergeMixin (array) and MixinMap (object) syntax, matching useStore() patterns

    const { 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 uses ctx.mixin() for code reuse

[0.13.0] - 2024-12-27

Added

  • persist() middleware now supports persistedOnly option for opt-in persistence mode

    import { 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) → persistedOnlyfilter option


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 accepts toJSON option to control serialization behavior

    const 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 unmount

    const { 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.fields now supports arrays for applying meta to multiple fields at once

    meta: [notPersisted.for(["password", "token"])];
    
  • MetaQuery.fields(type, predicate?) method to get field names with a specific meta type

    const sessionFields = ctx.meta.fields(sessionStore); // ['token', 'userId']
    const highPriority = ctx.meta.fields(priority, (v) => v > 5);
    
  • applyFor now supports object form to map patterns to different middleware

    applyFor({
      userStore: loggingMiddleware,
      "auth*": [authMiddleware, securityMiddleware],
      "*Cache": cacheMiddleware,
    });
    

Removed

  • BREAKING: useLocalStore hook removed - use scoped() in useStore selector 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. Use get() for cached services or scoped() for component-local stores instead.

Changed

  • BREAKING: persist API refactored for better encapsulation (renamed from persistMiddleware)

    • New handler option replaces load/save callbacks
    • persistMiddleware is now deprecated, use persist instead
    • Handler receives PersistContext (extends StoreMiddlewareContext with store instance)
    • Handler returns { load, save } object (can be sync or async)
    • onError signature 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),
        };
      },
    });
    

0.8.0 - 2024-12-21

Added

  • Persist Module (storion/persist)
    • persist(options) for automatic state persistence
    • notPersisted meta for excluding stores or fields from persistence
    • Supports sync and async load/save handlers
    • force option to override dirty state during hydration
  • Meta System
    • meta() function for creating typed metadata builders
    • MetaType.for(field) and MetaType.for([fields]) for field-level meta
    • MetaQuery with .all() and .any() query methods
    • withMeta(factory, entries) for attaching meta to factories
    • Meta available in middleware via ctx.meta(type)
  • Middleware Utilities
    • forStores(middleware) helper for store-only middleware
    • applyFor(patterns, middleware) for conditional middleware
    • applyExcept(patterns, middleware) for exclusion patterns
  • store.hydrate(state, { force }) - force option to override dirty properties

Changed

  • StoreMiddlewareContext now includes meta property for querying store metadata
  • FactoryMiddlewareContext now includes meta property for querying factory metadata

0.7.0 - 2024-12-15

Added

  • DevTools Module (storion/devtools)
    • devtoolsMiddleware() for state inspection
    • __revertState and __takeSnapshot injected 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 custom withStore implementations
  • create() 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 loading
    • async.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 tracking
    • ctx.cleanup() for teardown logic
    • ctx.refresh() for manual re-execution
    • Error handling strategies: "throw", "ignore", "retry"
  • batch(fn) for batching multiple state updates
  • untrack(fn) for reading state without tracking

0.4.0 - 2024-11-01

Added

  • Middleware System
    • container({ middleware: [...] }) for middleware injection
    • Middleware receives MiddlewareContext with type, next, resolver
    • Discriminated union: StoreMiddlewareContext vs FactoryMiddlewareContext
  • createLoggingMiddleware() built-in middleware
  • createValidationMiddleware() 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 disposal
    • lifetime: "autoDispose" - disposes when no subscribers
    • store.dispose() method
    • store.subscribe(listener) for change notifications
  • store.dehydrate() for serializing state
  • store.hydrate(state) for restoring state
  • store.dirty property tracking modified fields
  • store.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 instances
    • get(factory) for resolving dependencies
    • Services (plain factories) support
    • mixin(factory) for setup-time composition
  • StoreProvider React component
  • useContainer() hook

Changed

  • Stores are now lazily instantiated via container

0.1.0 - 2024-09-15

Added

  • Core Store
    • store(options) factory function
    • state - reactive state object
    • actions - returned from setup() function
    • update(producer) for Immer-style nested updates
  • React Integration
    • useStore(selector) hook with auto-tracking
    • useLocalStore(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)
    • shallowEqual
    • deepEqual

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;
  });
});

Released under the MIT License.