Store
Framework-agnostic reactive state with @reactive class fields.
A store is a plain class whose fields are made reactive with @reactive. It holds state — no render(), no DOM. Any component, effect, or framework integration that reads a store field tracks it as a signal dependency and re-runs when it changes.
import { reactive, computed } from "elements-kit/signals";
// ── Store ─────────────────────────────────────────────────────────────────────
export class CartItem {
constructor(
public name: string,
public price: number,
public qty = 1,
) {}
}
export class CartStore {
@reactive() items: CartItem[] = [];
@reactive() discount = 0;
subtotal = computed(() =>
this.items.reduce((s, i) => s + i.price * i.qty, 0),
);
total = computed(() => this.subtotal() * (1 - this.discount / 100));
add(name: string, price: number) {
const existing = this.items.find((i) => i.name === name);
if (existing) {
existing.qty++;
this.items = [...this.items]; // trigger reactivity
} else {
this.items = [...this.items, new CartItem(name, price)];
}
}
remove(name: string) {
this.items = this.items.filter((i) => i.name !== name);
}
}
export const cart = new CartStore();
export const PRODUCTS = [
{ name: "Widget", price: 9.99 },
{ name: "Gadget", price: 24.99 },
{ name: "Doohickey", price: 4.99 },
];
Create a shared store
import { , } from "elements-kit/signals";
export class {
@() : { : string; : number }[] = [];
@() = 0;
= (() =>
this..((, ) => + ., 0),
);
= (() => this.() * (1 - this.));
(: { : string; : number }) {
this. = [...this., ];
}
(: string) {
this. = this..(() => . !== );
}
}
export const = new ();Stores are framework-agnostic — the same instance drives a custom element, a React component, and a plain effect, all in sync.
Reading from a store
@reactive() installs a get / set accessor on the class field. Reading the field calls the signal's read function — so anywhere you pass () => store.field, you create a live subscription.
import { effect, computed } from "elements-kit/signals";
import { cart } from "./cart-store";
// Effect — reruns when cart.items or cart.discount changes
effect(() => {
console.log(cart.items.length, "items, total:", cart.total());
});
// In an element — live text binding
<p>Total: {() => cart.total()}</p>
// Compose into another computed
const isEmpty = computed(() => cart.items.length === 0);
// In React (via useSignal)
const items = useSignal(() => cart.items);
const total = useSignal(cart.total); // Computed<T> directlySingleton vs. instance
Singleton — one shared instance for the whole app or a module:
// cart-store.ts
export const cart = new CartStore();
// anywhere.ts
import { cart } from "./cart-store";
cart.add({ name: "Widget", price: 9.99 });Instance — a new store per component or subtree:
class TodoComponent {
store = new TodoStore(); // private, scoped to this component
render() {
return <ul>{() => this.store.todos.map(...)}</ul>;
}
}Store with actions
Group mutations as methods. Methods are plain functions — they write to @reactive fields and the reactivity system handles the rest.
import { , } from "elements-kit/signals";
export class {
@() : { : string; : string } | null = null;
@() = false;
= (() => this.?. === "admin");
async (: string, : string) {
this. = true;
try {
this. = await api.login(, );
} finally {
this. = false;
}
}
() {
this. = null;
}
}
export const = new ();Composing stores
Stores can read from each other — just access their fields inside a computed:
export class UIStore {
@reactive() sidebarOpen = true;
// Derived from another store
pageTitle = computed(() =>
auth.user ? `Welcome, ${auth.user.name}` : "Sign in",
);
}
export const ui = new UIStore();