Defining a Store#

Everything in @sigx/store starts with defineStore. It takes a name (used as a debug/instance-name prefix) and a setup function. The setup receives a context with defineState and defineActions, and returns an object describing the store: state, get, actions, events, and an optional name.

TypeScript
import { defineStore } from '@sigx/store';

const useStore = defineStore('my-store', (ctx) => {
    // ctx.defineState(...), ctx.defineActions(...)
    return { /* state, get, actions, events */ };
});

defineStore returns a factory function. Calling that factory creates an instance. By convention it is named useXxxStore, but it is just a function — there is no separate useStore export.

State#

defineState(obj) turns a plain object into reactive, signal-backed state. It returns three things:

TypeScript
const { state, events, mutate } = ctx.defineState({ count: 0, name: 'Ada' });
  • state — the reactive object. Read and write properties directly; deep watching is on, so nested object changes also trigger events.
  • events — one onMutated{Key} subscriber per property (the key is capitalized): onMutatedCount, onMutatedName.
  • mutate — one helper per property that accepts a value or an updater function.
TypeScript
state.count = 5;                  // direct assignment
mutate.count(42);                 // set via helper
mutate.count(prev => prev + 1);   // functional update (errors in the updater are caught and logged)

events.onMutatedCount.subscribe(value => {
    console.log('count changed to', value);
});

Both direct assignment and mutate fire the matching onMutated{Key} event with the new value.

Derived / computed getters#

@sigx/store does not ship its own getter helper. Build derived values with computed from sigx and expose them under the get field of your setup return. Computed values are lazy and cached, and they track the signal-backed state they read.

TSX
import { computed } from 'sigx';
import { defineStore } from '@sigx/store';

const useCartStore = defineStore('cart', ({ defineState }) => {
    const { state, mutate } = defineState({
        items: [
            { name: 'Keyboard', price: 80, qty: 1 },
            { name: 'Mouse', price: 30, qty: 2 },
        ],
    });

    const total = computed(() =>
        state.items.reduce((sum, item) => sum + item.price * item.qty, 0)
    );

    const itemCount = computed(() =>
        state.items.reduce((sum, item) => sum + item.qty, 0)
    );

    return {
        state,
        get: { total, itemCount },
    };
});

Read derived values via .value (the computed convention): store.get.total.value.

Actions#

defineActions(obj) wraps a set of functions. The returned object keeps your original callable actions and adds three event namespaces — one entry per action.

TypeScript
const actions = ctx.defineActions({
    add: (a: number, b: number) => a + b,
    fetchData: async () => 'data',
});

actions.add(3, 7); // => 10

Each action gains lifecycle events:

  • actions.onDispatching.<action>.subscribe(fn) — runs before the action body, with the original args.
  • actions.onDispatched.<action>.subscribe(fn) — runs after the action, with (result, ...args). For async actions, this fires after the returned promise resolves.
  • actions.onFailure.<action>.subscribe(fn) — runs if the action throws, with (failureReason, ...args).
TypeScript
actions.onDispatching.add.subscribe((a, b) => {
    console.log('about to add', a, b);
});
actions.onDispatched.add.subscribe((result, a, b) => {
    console.log('added', a, b, '=>', result);
});
actions.onFailure.add.subscribe((error, a, b) => {
    console.error('add failed', error);
});

Errors thrown inside an action are caught: onFailure fires, the error is logged via console.error, and the call returns undefined (the error is not re-thrown).

For sync actions the order is: onDispatching → action body → onDispatched. For async actions, onDispatched waits for the promise to resolve.

Custom events#

Beyond the automatic onMutated{Key} events, you can expose your own topic-based events under the events field of the setup return. Every subscriber returns a Subscription with unsubscribe().

A complete store#

Putting state, getters, and actions together:

TSX
import { computed } from 'sigx';
import { defineStore } from '@sigx/store';

const useTodoStore = defineStore('todos', ({ defineState, defineActions }) => {
    const { state, mutate } = defineState({
        todos: [] as { id: number; text: string; done: boolean }[],
        nextId: 1,
    });

    const remaining = computed(() => state.todos.filter(t => !t.done).length);

    const actions = defineActions({
        add(text: string) {
            const id = state.nextId;
            mutate.todos(prev => [...prev, { id, text, done: false }]);
            mutate.nextId(prev => prev + 1);
        },
        toggle(id: number) {
            mutate.todos(prev =>
                prev.map(t => (t.id === id ? { ...t, done: !t.done } : t))
            );
        },
        clearCompleted() {
            mutate.todos(prev => prev.filter(t => !t.done));
        },
    });

    return {
        state,
        get: { remaining },
        actions,
    };
});

Next steps#