Using Stores in Components#

A store factory returns a live instance with a flat, signal-backed surface. When you read store.x inside a component's render function, SignalX tracks the dependency and re-renders that component whenever the value changes. This guide covers the rendering model, the destructuring footgun, component-scoped stores, and cleanup.

How rendering reactivity works#

The store instance is an unwrap proxy: reading store.count reads the underlying key signal, and reading store.total reads the underlying computed. Reactivity then follows the normal SignalX rules — what matters is where the read happens:

  • Inside a render function (return () => <... {store.count} ...>): the read is tracked. That component — and only that component — re-renders when count changes. There is no store-level subscription; granularity is per read, per component.
  • Inside an effect or watch: tracked the same way; the effect re-runs on change.
  • In the component setup body (outside the returned render function): the read is a one-time snapshot. const n = store.count in setup gives you the value at mount time and never updates — by design, just like reading a signal's .value outside a reactive context.

So the rule of thumb is simple: keep reads on the store object, inside the render function. The proxy does the unwrapping; the signal core does the tracking.

TSX
Loading preview...

Returned computeds work the same — read them like plain (read-only) values and they update as the underlying state changes:

TSX
Loading preview...

The destructuring footgun#

The one way to lose reactivity is to destructure state off the store. const { count } = store reads through the unwrap proxy at that moment — you get a plain number, not a signal, and it will never update. The same applies to computeds.

When you want destructured locals, use storeToSignals: it returns signal views ({ value }) for every state key and computed, safe to pull apart. See the difference live — click increment and watch the first row stay frozen:

TSX
Loading preview...

Destructuring safely with storeToSignals#

TSX
import { storeToSignals } from '@sigx/store';

const store = useTodoStore();
const { todos, remaining } = storeToSignals(store);

todos.value.push({ id: 1, text: 'hi', done: false }); // still reactive
remaining.value;                                       // read-only computed view

Actions don't need this — they are plain functions and can be referenced freely (const { add } = store is fine for an action, though calling through the store keeps things uniform). storeToSignals skips functions and $-meta entirely; how its return type is inferred is covered in TypeScript.

Async actions and pending#

Every action carries a reactive pending flag — true while any invocation of that action is in flight. Read it in render to drive spinners and disabled states:

TSX
const Saver = component(() => {
    const store = useSaveStore();
    return () => (
        <button onClick={() => store.save()} disabled={store.save.pending}>
            {store.save.pending ? 'Saving…' : 'Save'}
        </button>
    );
});

Async actions return the original promise, so await store.save() observes rejections; un-awaited (fire-and-forget) calls never surface unhandled-rejection noise — failures still reach onFailure subscribers. Overlap counting, optimistic updates, and race patterns are covered in Actions & Async.

Reacting to specific changes#

To run side effects when a single key changes, subscribe to its $events entry. Callbacks receive (value, prev); each subscribe returns a Subscription you can unsubscribe() later.

TSX
const store = useCounterStore();

const sub = store.$events.count.subscribe((value, prev) => {
    document.title = `Count: ${value} (was ${prev})`;
});

// later, when you no longer need it:
sub.unsubscribe();

These events are lazy — the deep watcher behind a key only runs while it has subscribers, and nothing fires at subscribe time. Action lifecycle events (store.save.onFailure, …) work the same way for cross-cutting concerns like logging. The full event model — state events vs. action events vs. custom events, and devtools discovery — is in Events & Observability.

Sharing one instance vs. fresh instances#

The third argument to defineStore is the instance lifetime — a string literal, defaulting to 'scoped':

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

const useAppStore = defineStore('app', setup, 'singleton');
  • 'singleton' — one shared instance per app (AppContext), created on first use and disposed on app.unmount(). Outside any app context, one instance per JS realm. Ideal for global app state.
  • 'scoped' (default) — the nearest instance provided via defineProvide in the component tree; falls back to the app-context instance when no provider exists.
  • 'transient' — a new instance every time the factory is called, auto-disposed with the calling component.

Pick 'singleton' when several components must read and write the same state. Pick 'transient' when each component should own an isolated copy. For the full decision guide — including parameterized stores (arguments honored at first creation only for non-transient lifetimes) and the SSR app-per-request contract — see Lifetimes & DI.

Component-scoped stores with defineProvide#

A 'scoped' store resolves to the nearest provided instance up the component tree. defineProvide(useXxxStore) creates a fresh instance owned by the current component and provides it to the subtree — every descendant that calls the factory gets that instance instead of the app-wide one:

TSX
import { component, defineProvide } from 'sigx';
import { defineStore } from '@sigx/store';

const usePanelStore = defineStore('panel', ({ defineState, defineActions }) => {
    const { state, signals } = defineState({ expanded: false });
    const actions = defineActions({
        toggle() { state.expanded = !state.expanded; },
    });
    return { ...signals, ...actions };
}); // 'scoped' by default

const Panel = component(() => {
    // This Panel owns its instance; children resolve it automatically.
    const store = defineProvide(usePanelStore);
    return () => (
        <section>
            <PanelHeader />
            {store.expanded && <PanelBody />}
        </section>
    );
});

const PanelHeader = component(() => {
    const store = usePanelStore(); // the nearest Panel's instance
    return () => <button onClick={() => store.toggle()}>toggle</button>;
});

Mount three <Panel />s and you get three independent stores; each disposes when its panel unmounts. This is the middle ground between a shared singleton and a per-call transient: shared within a subtree, isolated between subtrees. defineProvide also accepts a custom factory (defineProvide(useXxxStore, () => instance)) when you need to pre-configure the instance — details in Lifetimes & DI.

Cleanup on unmount#

When you create a store instance inside a component, it is tied to a lifecycle per the lifetime rules:

  • 'transient' instances and defineProvided instances dispose with the component that created them.
  • 'singleton' and app-level 'scoped' instances are app-owned — they dispose on app.unmount(), not when the first component that touched them unmounts.

Disposal stops the state watchers and destroys all the instance's event topics, so $events and action events stop firing. If you create an instance outside a component (scripts, tests), dispose it yourself:

TypeScript
const store = useCounterStore();
store.count = 1;   // subscribers fire

store.$dispose();  // stop watchers + destroy topics
store.count = 2;   // subscribers no longer fire

Each instance has a friendly id, store.$id (e.g. 'counter#1') — handy in logs and devtools.

Next steps#

  • Actions & Asyncpending, lifecycle events, optimistic updates, races.
  • Lifetimes & DI — lifetimes in practice, defineProvide, parameterized stores, SSR.
  • Persistence — persist state slices with @sigx/store/persist.
  • API Reference — full signatures for every export.