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.

cart.tsmain.tsx
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> directly

Singleton 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();

See also