# Introduction
Source: https://elements-kit.com/
**ElementsKit is a library of reactive primitives.** Pick one, use it in plain JavaScript, React, a custom element, or on the server. Everything builds on one small core; JSX, custom elements and the UI Kit are modules you add only if you want them.
Every piece is its own import, so you ship only what you use.
* **Compose, don't configure.** Small focused APIs: `signal`, `computed`, `on`, `fromEvent`, `async`. Combine primitives instead of maintaining an overloaded interface.
* **Close to the platform.** `promise` extends `Promise`. Custom elements *are* `HTMLElement`. JSX compiles to `document.createElement`. No virtual DOM, no proxies.
* **Predictable and explicit, no magic.** Signals are reactive; nothing else is. No hidden subscriptions.
* **Designed for the AI age.** Code is cheap; maintenance still isn't. Swap one block at a time.
## Install
```sh
pnpm add elements-kit
```
```sh
npm install elements-kit
```
```sh
yarn add elements-kit
```
```sh
bun add elements-kit
```
Full setup (TypeScript JSX config, CDN, Deno) in [Installation](/getting-started/installation).
```ts
import { signal, computed, effect } from "elements-kit/signals";
import { online } from "elements-kit/utilities/network";
import { createMediaQuery } from "elements-kit/utilities/media-query";
import { on } from "elements-kit/utilities/event-listener";
const clicks = signal(0);
const dark = createMediaQuery("(prefers-color-scheme: dark)");
const label = computed(() => `${clicks()} clicks · ${online() ? "online" : "offline"}`);
const button = document.querySelector("button")!;
on(button, "click", () => clicks(clicks() + 1));
effect(() => {
button.textContent = label();
document.body.classList.toggle("dark", dark());
});
```
```tsx
/** @jsxImportSource react */
import { signal, computed, effect } from "elements-kit/signals";
import { useSignal, useScope } from "elements-kit/integrations/react";
// Signals live outside React, shared with any other consumer.
const count = signal(0);
const doubled = computed(() => count() * 2);
export default function Counter() {
const value = useSignal(count);
const double = useSignal(doubled);
useScope(() => {
effect(() => console.log("count:", count()));
});
return (
{" "}
);
}
render(document.getElementById("app")!, () => );
```
```tsx
import { signal } from "elements-kit/signals";
import { async } from "elements-kit/utilities/async";
import { render } from "elements-kit/render";
const query = signal("hello");
const search = async(async () => {
const res = await fetch(`/api/search?q=${query()}`);
return res.json();
}).start();
render(document.getElementById("app")!, () => (
query((e.target as HTMLInputElement).value)}
/>
state: {() => search.state}
{() => JSON.stringify(search.value, null, 2)}
));
```
Same signals in every tab. See the full progression (signals → element → component → custom element) in the [Quick start](/getting-started/quick-start).
> **Built for the AI age**
>
> Explicit contracts survive edits by humans or agents. Machine-readable index: [`/llms.txt`](/llms.txt) (short) · [`/llms-full.txt`](/llms-full.txt) (every page concatenated).
## Start here
- [Installation](/getting-started/installation): Install the package and configure JSX in your TypeScript project.
- [Quick start](/getting-started/quick-start): Build a reactive counter five ways, from signals to custom elements.
- [Philosophy](/getting-started/philosophy): Four principles behind the API: compose don't configure, close to the platform, predictable and explicit, designed for the AI age.
## Explore
- [Signals](/signals): Core: signal · computed · effect · batch · onCleanup
- [Stores](/stores): Core: @reactive classes shared across components and frameworks
- [Primitives catalog](/primitives): Every primitive, by area, with where it runs
- [Async & promise](/primitives/async): Primitives: queries, mutations, revalidation without a query library
- [Routing](/primitives/routing): Primitives: location, URL patterns and navigate
- [Form object](/primitives/form-object): Primitives: read and write a form as a nested object
- [JSX elements](/elements): Rendering: JSX → real DOM with live bindings
- [Custom elements](/custom-elements): Rendering: native HTMLElement with signals and decorators
- [React](/integrations/react): Integrations: useSignal · useScope
- [Data fetching recipe](/examples/data-fetching): Example: async + retry + online + window focus
***
ElementsKit is built and maintained by the [Quba](https://www.quba.co) team.
---
# Installation
Source: https://elements-kit.com/getting-started/installation
Install the package and point TypeScript at the ElementsKit JSX runtime. No build-step config on the consumer side — everything runs through your existing bundler.
## Install
```sh
pnpm add elements-kit
```
```sh
npm install elements-kit
```
```sh
yarn add elements-kit
```
```sh
bun add elements-kit
```
```sh
deno add npm:elements-kit
```
```html
```
## Configure JSX
Point TypeScript at the ElementsKit JSX runtime. Required if you write JSX; skip otherwise.
```json
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "elements-kit"
}
}
```
Per-file pragma is also supported if you need JSX from a different source elsewhere in the project:
```tsx
/* @jsxImportSource elements-kit */
```
## Verify
A minimal script confirms the install works:
```ts
import { signal, effect } from "elements-kit/signals";
const count = signal(0);
effect(() => console.log("count:", count()));
count(1); // → count: 1
count(2); // → count: 2
```
## See also
* [Quick start](/getting-started/quick-start)
* [Philosophy](/getting-started/philosophy)
* [Signals](/signals)
---
# Quick start
Source: https://elements-kit.com/getting-started/quick-start
Start with signals, grow into a component, end as a native HTML tag. Each step below adds one layer on top of the previous — the reactive core never changes, only how you package it.
## From signal to custom element
```tsx
// 5. Custom element. The same counter, usable as .
import { reactive, computed } from "elements-kit/signals";
import { attributes, ATTRIBUTES as attr } from "elements-kit/attributes";
import { render } from "elements-kit/render";
@attributes
class CounterElement extends HTMLElement {
static [attr] = {
initial(this: CounterElement, v: string | null) {
this.count = Number(v ?? 0);
},
};
@reactive() count = 0;
doubled = computed(() => this.count * 2);
#unmount?: () => void;
#template = () => (
{() => this.count} × 2 = {this.doubled}
);
connectedCallback() {
this.#unmount = render(this, this.#template);
}
disconnectedCallback() {
this.#unmount?.();
this.#unmount = undefined;
}
}
customElements.define("x-counter", CounterElement);
```
## What's happening
* **Step 1 — signals.** `signal(0)` holds state; `computed(() => count() * 2)` derives from it. Both are framework-agnostic — no DOM involved.
* **Step 2 — signals + JSX.** `render(target, setup)` runs `setup` inside an [`effectScope`](/scopes), appends the returned node to `target`, and hands back a single `unmount` thunk. `{count}` creates a live text node that updates in place — no diffing, no re-render.
* **Step 3 — function component.** Props are typed with `Props`; each key on `props` arrives as a `Computed` (callable getter). Return type is `Element` — a real DOM node.
* **Step 4 — class component.** `@reactive()` turns class fields into signals with natural property access. `render(): Element` returns the same shape, called once when the instance mounts.
* **Step 5 — custom element.** `@attributes` wires the `static [attr]` map so HTML attributes feed reactive properties. `connectedCallback` mounts; `disconnectedCallback` tears down. Usable as a plain HTML tag in any framework or no framework at all.
The reactive core (steps 1–2) is doing all the work. Steps 3–5 are just packaging.
## Next steps
* Learn the signal primitives in depth → [Signals](/signals).
* Understand JSX → DOM compilation → [Elements](/elements).
* Wrap the same pattern in a native custom element → [Custom elements](/custom-elements).
## See also
* [Installation](/getting-started/installation)
* [Philosophy](/getting-started/philosophy)
* [Scopes](/scopes)
---
# Philosophy
Source: https://elements-kit.com/getting-started/philosophy
ElementsKit is a library of reactive primitives, not a framework. Each piece is its own import, runs on its own, and composes with the others — inside React, inside a custom element, or on its own in a script.
## Principles
Four threads run through every API choice:
**Compose, don't configure**
Small focused APIs — `signal`, `computed`, `on`, `fromEvent`, `async`. Combine primitives instead of maintaining an overloaded interface.
**Close to the platform**
Thin or absent abstraction — no virtual DOM, no proxies, no build steps. JSX is `createElement`, custom elements *are* `HTMLElement`.
**Predictable and explicit**
`signal` / `compose` are reactive; nothing else is. No heuristic dependency tracking, no hidden subscriptions.
**Designed for the AI age**
Primitives compose into higher-level blocks. Swap one block at a time instead of maintaining long lines of code.
### Compose, don't configure
ElementsKit gives you primitives you combine, not a system you configure. No config object, no plugin tree, no convention to memorize — the call site is the contract. Need a query layer? Compose `async` + `retry` + storage — swap the fetcher; leave the retry and storage alone. Need browser-API hooks? Import the utility. The surface grows by adding primitives, not by stacking flags on an existing one — overloaded interfaces accumulate breaking changes and deprecations that every consumer has to track and migrate through.
The table below maps ElementsKit equivalents to each major feature from the usual stack — a **UI framework** (React, Vue, Solid), a **custom-element** library (Lit), a **server-state** library (TanStack Query, SWR), a **browser-hooks** pack (react-use, vueuse, solid-primitives), and a **state** manager (Zustand, Jotai, MobX).
| Layer | Feature | ElementsKit |
| ------------------- | --------------------- | -------------------------------------------------- |
| **State** | Shared reactive state | `class Store { @reactive() count = 0 }` |
| | Derived value | `computed(() => store.count * 2)` |
| **Server state** | Query | `async(() => fetch(url())).start()` |
| | Mutation | `mutation.run({ id, name })` |
| | Retry with backoff | `async(retry(fn, 5, n => 2**n * 100)).start()` |
| | Persist result | `async(fetcher.then(v => store(v))).start()` |
| **Browser hooks** | Online / offline | `online()` |
| | Viewport size | `windowSize.width()` |
| | Media query | `createMediaQuery("(prefers-color-scheme: dark)")` |
| | Debounce | `createInterval(300)` |
| **UI framework** | Mount into DOM | `render(el, () => )` |
| | Function component | `function Counter(props) { return }` |
| | Class component | `class Counter { render() { return } }` |
| | Lifecycle cleanup | `onCleanup(() => …)` |
| **Custom elements** | Reactive HTMLElement | `@attributes class X extends HTMLElement {}` |
| | Reactive property | `@reactive() count = 0` |
| | Attribute handler | `static [attr] = { name(this, v) {} }` |
| | Slot | `children = new Slot()` |
### Close to the platform
Heavy abstraction over native APIs is where beginners get burned — they learn the abstraction, not the platform underneath, and the leak surfaces in production. ElementsKit keeps the layer thin or absent. JSX compiles to `document.createElement`. `promise` *extends* the native `Promise` (still awaitable, still chainable). `async` instances are thenable. Custom elements *are* `HTMLElement`.
| ElementsKit | Native API | Relationship |
| -------------------- | ------------------------ | ------------------------------------------------ |
| JSX | `document.createElement` | Compiles to |
| `promise` | `Promise` | Extends (still awaitable, still chainable) |
| `async` | `Promise` | Thenable — `await asyncInstance` works |
| Custom element | `HTMLElement` | Extends — registered via `customElements.define` |
| `@reactive` field | Plain class field | Decorator — no proxy, no property rewrite |
| `on(el, "click", …)` | `addEventListener` | Thin wrapper returning `Disposable` |
### Predictable and explicit
No magic. `signal` / `compose` are reactive; nothing else is. No heuristic dependency tracking, no hidden subscriptions. Derivations live in `computed(...)`, cleanup in `onCleanup`. Stores are plain classes with `@reactive` field decorators.
```tsx
// Framework — magic. Reactive tracking is implicit;
// dep arrays, reducer indirection, StrictMode double-fires.
function Counter() {
const [s, dispatch] = useReducer((s, a) => {
switch (a.type) {
case "inc": return { n: s.n + 1 };
}
}, { n: 0 });
const doubled = useMemo(() => s.n * 2, [s.n]);
useEffect(() => {
const id = setInterval(() => dispatch({ type: "inc" }), 1000);
return () => clearInterval(id);
}, []);
return
{s.n} / {doubled}
;
}
```
### Designed for the AI age
Writing code is cheap — an agent will draft most of it. *Maintaining* it is the bottleneck. A codebase is read and rewritten long after it's first authored, often by an agent rebuilding context from scratch.
ElementsKit is built from primitives that compose into higher-level blocks. Each unit stays testable in isolation, which lets you — or an agent — swap one block at a time without touching the rest.
## Framework integration
Any signal can drive a UI-framework component. The hooks are the bridge, not the primitive — the same utilities work inside React today, Solid or Vue when those integrations ship.
Two hooks bridge signals into React: **`useSignal`** subscribes a component to any signal or computed; **`useScope`** ties `effect`, `onCleanup`, and factory utilities to the component's lifetime for automatic disposal. See [React integration](/integrations/react) for the full treatment.
```tsx
// effect + onCleanup inside useScope — torn down with the component.
/** @jsxImportSource react */
import { effect, onCleanup } from "elements-kit/signals";
import { useScope } from "elements-kit/integrations/react";
import { online } from "elements-kit/utilities/network";
export function NetworkLogger() {
const offline = useScope(() => {
effect(() => {
console.log("online:", online());
});
const id = setInterval(() => console.log("tick"), 1000);
onCleanup(() => clearInterval(id));
return computed(() => !online());
});
return
Watching network in the console.
;
}
```
## Architecture
The pieces are independent and composable — not a strict hierarchy. Signals are the shared language that connects everything.
## Pattern guide
| Pattern | When to use it |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Plain signals | Pure logic, no UI. Node scripts, workers, utilities that need reactive state. |
| Elements / JSX | Inline reactive UI in scripts. Quick components appended directly to the DOM. |
| Components | Class-based UI with `render()`. Share state across multiple elements via stores. |
| Custom elements | Reusable across frameworks. Native HTML integration, Shadow DOM, framework-agnostic distribution. |
| Framework integration | Drop a signal, store, or utility into an existing React (today; Solid/Vue planned) app via `useSignal` / `useScope`. Share reactive state with code that lives outside the framework. |
Start with the simplest option that fits — you can always combine patterns.
## Not for
> **Reach for something else if you need**
>
> ElementsKit is deliberately narrow.
>
> * **Server-side rendering or streaming HTML.** No SSR, no hydration, no React Server Components. Rendering happens in the browser.
> * **A complete app framework.** No router, no data layer, no file-system conventions — bring or compose your own.
> * **A pre-built component library.** This ships primitives, not components. A design system on top has to come from you or a third party.
Narrow doesn't mean incomplete — signals + elements + custom elements cover the full client-side reactive-UI surface.
## See also
* [Installation](/getting-started/installation)
* [Quick start](/getting-started/quick-start)
* [Signals](/signals)
* [Elements](/elements)
---
# Signals
Source: https://elements-kit.com/signals
Signals are the reactive core of ElementsKit. Every UI update, derived value, and side effect is driven by three primitives: `signal`, `computed`, and `effect`.
## signal
A `signal` holds a single reactive value. Call it with no arguments to **read**, with an argument to **write**.
```ts
import { signal } from "elements-kit/signals";
const count = signal(0);
count(); // Read → 0
count(5); // Write — notify all dependents
count(); // Read → 5
```
```ts
import { signal } from "elements-kit/signals";
const name = signal("Alice");
// ^?
name(); // "Alice"
name("Bob"); // write
name(); // "Bob"
```
Signals are synchronous — every write immediately notifies dependents.
> **Reactive children need `() =>`**
>
> In JSX, signal/computed values must be wrapped in a function:
>
> ```tsx
> // ❌ Wrong — reads once, never updates
>
{count()}
>
> // ✅ Correct — reads on every change
>
{() => count()}
> ```
## computed
A `computed` derives a value from one or more signals. It is **lazy** — only re-evaluates when a dependency has changed and the value is read again. Results are cached between reads.
```ts
import { signal, computed } from "elements-kit/signals";
const price = signal(10);
const qty = signal(3);
const total = computed(() => price() * qty());
total(); // → 30
price(20);
total(); // → 60 (recomputed — price changed)
qty(1);
total(); // → 20 (recomputed again)
```
```ts
import { signal, computed } from "elements-kit/signals";
const firstName = signal("Ada");
const lastName = signal("Lovelace");
const fullName = computed(() => `${firstName()} ${lastName()}`);
// ^?
fullName(); // "Ada Lovelace"
firstName("Grace");
fullName(); // "Grace Lovelace"
```
A `computed` is read-only — you can only call it with no arguments.
***
## effect
An `effect` runs a side effect whenever its signal dependencies change. It runs **immediately** on creation to collect dependencies, then re-runs on every change. Returns a stop function.
```ts
import { signal, effect } from "elements-kit/signals";
const count = signal(0);
const stop = effect(() => {
console.log("count:", count()); // count: 0
});
count(1); // → count: 1
count(2); // → count: 2
stop(); // Dispose — no more logs
```
```ts
import { signal, effect } from "elements-kit/signals";
const theme = signal("light");
const stop = effect(() => {
// ^?
// Reads theme — becomes a dependency automatically
document.body.dataset.theme = theme();
});
theme("dark"); // body.dataset.theme = "dark"
stop(); // Unsubscribe — no more updates
```
Effects track dependencies **dynamically** — only the signals actually read during the last run are tracked.
> **Reading signals outside effects**
>
> Signals only trigger updates when read inside a reactive context (effect, computed, or reactive JSX). Reading outside gives you the current value but won't re-run:
>
> ```ts
> const name = signal("Alice");
>
> // Logs once — not reactive
> console.log(name());
>
> // Re-runs whenever name changes
> effect(() => console.log(name()));
> ```
## effectScope
Groups multiple effects under a single lifecycle. Calling the returned stop function disposes **all** effects at once.
```ts
import { signal, effect, effectScope } from "elements-kit/signals";
const user = signal("Alice");
const page = signal("/home");
// All effects inside are grouped together
const stop = effectScope(() => {
// ^?
effect(() => console.log("user:", user()));
effect(() => console.log("page:", page()));
});
user("Bob"); // → user: Bob
page("/about"); // → page: /about
stop(); // Both effects disposed — silence
```
Use `effectScope` when you want to start and stop a group of related effects together — e.g. tied to a component's lifetime.
> **JSX already scopes for you**
>
> Function components, intrinsic elements, fragments, and `` items each create their own `effectScope`. You only need to create one by hand in custom-element lifecycle hooks or other non-JSX entry points — see [Scopes & cleanup](/scopes/).
***
## onCleanup
Registers a cleanup callback inside the currently running `effect` or `effectScope`. The callback runs **before the effect re-runs** and **when the effect is disposed**.
```ts
import { signal, effect, onCleanup } from "elements-kit/signals";
const url = signal("/api/data");
effect(() => {
const controller = new AbortController();
fetch(url(), { signal: controller.signal })
.then((r) => r.json())
.then(console.log);
// Runs before the next fetch (url changed) or on stop()
onCleanup(() => controller.abort());
});
url("/api/other"); // previous fetch aborted, new one starts
```
`onCleanup` works at **any call depth** inside the effect — you can call it anywhere without passing anything down.
You can use `onCleanup` inside a `computed` getter to manage resources that need cleanup (like timers or subscriptions). The cleanup runs **before the computed re-evaluates** (when dependencies change), and also when the computed loses its last subscriber (to prevent resource leaks).
```ts
import { signal, computed, onCleanup } from "elements-kit/signals";
const source = signal(0);
const derived = computed(() => {
// Setup resource
const id = setInterval(() => {
// ...do something...
}, 1000);
// Register cleanup
onCleanup(() => clearInterval(id));
return source() * 2;
});
```
This ensures that any resources created inside the computed are always cleaned up at the right time.
***
## batch
Defers all signal notifications until the batch completes. Use it to group multiple writes into a single update pass — avoids intermediate re-renders.
```ts
import { signal, effect, batch } from "elements-kit/signals";
const x = signal(1);
const y = signal(2);
effect(() => console.log(x(), y()));
// → 1 2 (initial run)
// Without batch: effect would run twice (once per write)
batch(() => {
x(10);
y(20);
});
// → 10 20 (single run)
```
***
## untracked
Reads a signal **without** creating a dependency. The current effect will not re-run when that signal changes.
```ts
import { signal, effect, untracked } from "elements-kit/signals";
const count = signal(0);
const secret = signal("hidden");
effect(() => {
// Tracked — effect reruns when count changes
console.log("count:", count());
// Untracked — effect does NOT rerun when secret changes
console.log("secret:", untracked(() => secret()));
});
count(1); // triggers re-run
secret("x"); // silent — not a dependency
```
> **Mutating vs. reassigning**
>
> Arrays and objects are passed by reference. Mutating them (`push`, `pop`, `arr[0] = x`) doesn't notify subscribers — the signal value (reference) hasn't changed.
>
> ```ts
> const items = signal([]);
>
> // ❌ Won't trigger
> items().push("Hello");
> ```
>
> **Option 1 — Mutation + trigger** (for expensive data):
>
> ```ts
> const items = signal([]);
>
> items().push("hello");
> trigger(items); // ✅ Mutate in place, then notify
> ```
>
> **Option 2 — Reassignment**:
>
> ```ts
> const items = signal([]);
>
> items([...items(), "hello"]); // ✅ Creates new array
> ```
>
> The same applies to objects:
>
> ```ts
> const user = signal({ name: "Alice" });
>
> // ❌ Won't trigger
> user().name = "Bob";
>
> // ✅ Reassignment
> user({ ...user(), name: "Bob" });
>
> // ✅ Or mutation + trigger
> user().name = "Bob";
> trigger(user);
> ```
***
## @reactive decorator
Backs a class field with a signal transparently. The field reads and writes naturally — no `()` needed on the consumer side.
```ts
import { reactive, computed } from "elements-kit/signals";
class Counter {
@reactive()
count = 0;
doubled = computed(() => this.count * 2);
// ^?
}
const c = new Counter();
c.count = 5;
c.doubled(); // → 10
```
`@reactive` is equivalent to a private `signal` + `get`/`set` accessor — it just removes the boilerplate. Works with `computed` too: pass the signal source to make a field read-only.
```ts
class Store {
#items = signal([]);
// Read-only reactive field backed by a computed
@reactive((s) => computed(() => s.#items().length))
count = 0;
}
```
***
## Store
A **store** is a class with `@reactive` fields. It holds state — no `render()`, no DOM. See [Stores](/stores) for full documentation.
## See also
* [Stores](/stores) — shared reactive state.
* [Elements](/elements) — use signals as JSX children/props.
* [Promise](/primitives/promise) — reactive state over any `Promise`.
* [Async](/primitives/async) — start/stop/run a reactive async controller.
---
# Store
Source: https://elements-kit.com/stores
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.
## Create a shared store
```ts
import { reactive, computed } from "elements-kit/signals";
export class CartStore {
@reactive() items: { name: string; price: number }[] = [];
@reactive() discount = 0;
subtotal = computed(() =>
this.items.reduce((sum, i) => sum + i.price, 0),
);
total = computed(() => this.subtotal() * (1 - this.discount));
add(item: { name: string; price: number }) {
this.items = [...this.items, item];
}
remove(name: string) {
this.items = this.items.filter((i) => i.name !== name);
}
}
export const cart = new CartStore();
```
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.
```tsx
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
Total: {() => cart.total()}
// 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 directly
```
***
## Singleton vs. instance
**Singleton** — one shared instance for the whole app or a module:
```ts
// 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:
```tsx
class TodoComponent {
store = new TodoStore(); // private, scoped to this component
render() {
return
{() => this.store.todos.map(...)}
;
}
}
```
***
## Store with actions
Group mutations as methods. Methods are plain functions — they write to `@reactive` fields and the reactivity system handles the rest.
```ts
import { reactive, computed } from "elements-kit/signals";
export class AuthStore {
@reactive() user: { name: string; role: string } | null = null;
@reactive() loading = false;
isAdmin = computed(() => this.user?.role === "admin");
async login(email: string, password: string) {
this.loading = true;
try {
this.user = await api.login(email, password);
} finally {
this.loading = false;
}
}
logout() {
this.user = null;
}
}
export const auth = new AuthStore();
```
***
## Composing stores
Stores can read from each other — just access their fields inside a `computed`:
```ts
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
* [Signals](/signals)
* [Components](/components)
* [React integration](/integrations/react)
---
# Scopes
Source: https://elements-kit.com/scopes
Every `effect`, and `onCleanup` belongs to a **scope**. When the scope disposes, everything inside it tears down. Most JSX boundaries create a scope for you — knowing which ones, and where that scope is missing, is the whole contract.
## The table
| Boundary | Auto-scoped | Disposed when |
| --------------------------------------------------- | ----------------------------- | --------------------------------------------------------- |
| Function component body — `(props) => ` | yes | the element is removed |
| Intrinsic or class element — ``, `` | yes | the element is removed |
| Fragment — `<>...>` | yes (via the JSX transform) | the fragment's disposables run |
| Reactive child slot — `{() => signal()}` | yes | the slot updates or its parent unmounts |
| `` render callback, per item | yes | the item's key leaves `each`, or the list unmounts |
| Custom element `connectedCallback` | **no** — opt in with `render` | you call the returned `unmount` in `disconnectedCallback` |
| App root (`document.getElementById("app")`) | **no** — opt in with `render` | you call the returned `unmount` |
If you write `onCleanup(...)` or `effect(...)` inside any of the first five rows, the library owns its lifetime. At the boundaries below, you own it.
## `render` — mount with a scoped lifetime
`render(target, setup)` runs `setup` inside a detached `effectScope`, appends its returned node to `target`, and hands back a single `unmount` thunk. Calling `unmount` removes the node from the DOM, disposes its `Symbol.dispose` hook, and tears down every `effect` / `onCleanup` registered inside `setup`.
### Mounting an app
Typical entry point — mount a root component into the page container and keep a reference to the `unmount` thunk (e.g. for hot-module replacement):
```tsx
import { render } from "elements-kit/render";
import { App } from "./App";
const unmount = render(
document.getElementById("app")!,
() => as Element,
);
```
### Inside a custom element
Use the same helper in `connectedCallback`; stash the returned `unmount` on the element and call it from `disconnectedCallback`.
```tsx
import { onCleanup } from "elements-kit/signals";
import { render } from "elements-kit/render";
class Clock extends HTMLElement {
#unmount?: () => void;
#template = () => {
const el = document.createElement("time");
const id = setInterval(() => (el.textContent = String(Date.now())), 1000);
onCleanup(() => clearInterval(id));
return el;
};
connectedCallback() {
this.#unmount = render(this, this.#template);
}
disconnectedCallback() {
this.#unmount?.();
this.#unmount = undefined;
}
}
```
> **Effects must live inside the render callback**
>
> Put every `effect` and `onCleanup` **inside** the `render` callback. Anything created outside — in the constructor, a field initializer, or `attributeChangedCallback` — has no owning scope and will not stop when the element disconnects. The same rule applies to effects started from a timer or event handler: create them inside the `render` callback, not on first event. `computed` is exempt — it's lazy and disposes itself when it has no subscribers, so a class-field `computed` is fine.
`render` runs `setup` inside an `effectScope` detached from any enclosing effect — re-running an outer effect won't tear your scope down; only calling the returned `unmount` will.
## Why `For` items get their own scope
Each `` item runs its render callback inside its own `effectScope`. `onCleanup` registered during render fires when that item's key leaves `each` — not only on full-list unmount. This keeps per-row resources (subscriptions, intervals, observers) bound to the row's actual lifetime.
```tsx
r.id}>
{(row) => {
const off = subscribe(row.id);
onCleanup(off); // fires when this row is removed
return
;
}
```
**Factory utilities** with per-component lifetime go inside `useScope` so they are created and cleaned up with the component:
```tsx
import { useSignal, useScope } from "elements-kit/integrations/react";
import { createDebounced } from "elements-kit/utilities/debounced";
import { signal } from "elements-kit/signals";
const query = signal("");
function Search() {
const debounced = useScope(() => createDebounced(query, 300));
return (
<>
query(e.currentTarget.value)} />
Searching for: {debounced}
>
);
}
```
**Writable signals** from storage or scroll work as both getter (via `useSignal`) and setter (call directly):
```tsx
import { useSignal } from "elements-kit/integrations/react";
import { createLocalStorage } from "elements-kit/utilities/storage";
const theme = createLocalStorage("theme", "light");
function ThemeToggle() {
const current = useSignal(theme);
return (
);
}
```
***
## See also
* [Signals](/signals): the core every primitive returns
* [Scopes & cleanup](/scopes): how primitives release listeners and timers
* [Integrations](/integrations): React, Vue, Svelte and more
---
# Promise
Source: https://elements-kit.com/primitives/promise
The `promise` utility wraps any native `Promise`, executor function, or `ReactivePromise` into a `ComputedPromise` — an object that is both **awaitable** like a regular promise and **reactive** like a signal.
## Basic Usage
```ts
import { promise } from "elements-kit/utilities/promise";
// From an executor
const cp = promise((resolve) => {
setTimeout(() => resolve(42), 1000);
});
// From an existing Promise
const cp2 = promise(fetch("/api/data").then(res => res.json()));
```
## Awaitable
A `ComputedPromise` is a real `Promise` — you can `await` it, chain `.then()` / `.catch()` / `.finally()`, and use it anywhere a `Promise` is expected:
```ts
const data = await cp;
cp.then(value => console.log(value))
.catch(err => console.error(err));
```
## Reactive
Calling the `ComputedPromise` as a function inside an `effect` or `computed` tracks it as a reactive dependency:
* **Pending** → returns `undefined`
* **Fulfilled** → returns the resolved value
* **Rejected** → throws the rejection reason
```ts
import { effect } from "elements-kit/signals";
effect(() => {
try {
const value = cp(); // undefined while pending, T when fulfilled
console.log("value:", value);
} catch (err) {
console.error("error:", err);
}
});
```
## State Properties
All three state properties are reactive — reading them inside an `effect` or `computed` subscribes to changes:
```ts
cp.state; // "pending" | "fulfilled" | "rejected"
cp.value; // T | undefined
cp.reason; // E | undefined
```
## Result
`.result` is a convenience property that returns `T | E | undefined` — the resolved value when fulfilled, the rejection reason when rejected, or `undefined` while pending. Useful when you don't need to distinguish between fulfilled and rejected but just want the current value:
```ts
effect(() => {
console.log("current result:", cp.result);
});
```
## `ReactivePromise`
`ReactivePromise` is the lower-level class that powers `ComputedPromise`. Use it directly when you need to construct or wrap a promise and expose its reactive state without the `Computed` callable interface:
```ts
import { ReactivePromise } from "elements-kit/utilities/promise";
// From an executor
const rp = new ReactivePromise((resolve, reject) => {
fetch("/api/data")
.then(res => res.json())
.then(resolve)
.catch(reject);
});
// Wrap an existing promise
const rp2 = ReactivePromise.from(fetch("/api/data"));
rp.state; // "pending" | "fulfilled" | "rejected"
rp.value; // T | undefined
rp.reason; // E | undefined
rp.result; // PromiseResult
```
`cp instanceof ReactivePromise` returns `true` for any `ComputedPromise`.
## See also
* [Async](/primitives/async) — higher-level controller with `start` / `stop` / `run`.
* [Data fetching](/examples/data-fetching) — composed async recipe.
* [Signals](/signals)
---
# Async
Source: https://elements-kit.com/primitives/async
The `async` utility wraps an async function into a reactive, awaitable controller. You can start, stop, and rerun async operations, and read their state, value, and errors as reactive signals.
## Basic usage
```ts
import { async } from "elements-kit/utilities/async";
const fetchItems = async(() => fetch("/api/items").then((res) => res.json()));
fetchItems.start(); // begin reactive execution
```
## Control methods
Start for reactive execution, run for one-shot, stop to tear down, reset to clear.
```ts
op.start(); // run and track reactive dependencies — reruns when signals change
op.run(); // run once without tracking — does not rerun on signal changes
op.stop(); // stop reactive reruns and run cleanup logic
op.reset(); // stop, then clear state back to `idle`
```
### When to `start()` vs `run()`
`start()` enables fetching automatically. The body is tracked, so the query re-runs whenever a signal it reads before the first `await` changes. Reach for it when the load is parameterised by reactive state — "fetch user X whenever `userId` changes". The [data-fetching recipe](/examples/data-fetching) uses `start()` with reactive params.
`run()` is one-shot and untracked. Reach for it when an external event drives the load — intersection, click, form submit — especially when the body **writes** to the same signals it reads. The [infinite-scroll recipe](/examples/infinite-scroll) uses `run()` for sentinel-driven page loads.
> **`start()` cascades when the body writes its own deps**
>
> A tracked body that mutates a signal it depends on retriggers itself. Use `run()` for externally-driven loads instead.
>
> ```ts
> // Wrong — body reads `cursor`, body writes `cursor`. Each write retriggers the
> // tracking effect; on first mount this fetches every page in a tight loop.
> const loadMore = async(async () => {
> const page = await fetchPage(cursor());
> cursor(page.next);
> }).start();
>
> // Right — `run()` is untracked, so the write doesn't re-fire the body.
> // Drive it from an event (intersection, click, etc.).
> const loadMore = async(async () => {
> const page = await fetchPage(cursor());
> cursor(page.next);
> });
> sentinelInView.subscribe(() => loadMore.run());
> ```
`Async` implements `Symbol.dispose`, so `using` stops it automatically when it goes out of scope:
```ts
{
using op = async(() => fetch("/api/data").then((r) => r.json())).start();
await op;
console.log(op.value);
} // op.stop() called automatically here
```
## Reactive state
`Async` exposes the same reactive state interface as `ReactivePromise`, plus an
`"idle"` state for the window before the first `run()` / `start()`:
```ts
op.state; // "idle" | "pending" | "fulfilled" | "rejected"
op.value; // resolved value (T | undefined)
op.reason; // rejection reason (E | undefined)
op.result; // value if fulfilled, reason if rejected, undefined otherwise
op.pending; // true only while a run is in flight
```
Unlike a bare `ReactivePromise` (which wraps an already in-flight promise and so
starts `pending`), an `Async` is a lazy runner: with no request yet it starts
`idle` and `op.pending` is `false` until you trigger a run. Drive loading UI off
`op.pending` and it stays quiet until then.
All properties are reactive — reading them inside an `effect` or `computed` subscribes to changes.
## Callable signal
An `Async` instance is also callable as a signal. `op()` returns `op.result` and tracks it as a reactive dependency:
```ts
import { effect } from "elements-kit/signals";
effect(() => {
const result = op(); // undefined while pending, T when fulfilled, E when rejected
console.log(result);
});
```
This makes `Async` composable with `computed` and templates.
## Awaitable
`Async` implements `.then`, `.catch`, and `.finally`, so you can `await` it directly:
```ts
const op = async(() => Promise.resolve(123)).start();
const value = await op; // 123
```
## Reactive reruns
Read signals inside the async function to make it re-execute when they change. Only signal reads **before the first `await`** are tracked.
```ts
import { signal } from "elements-kit/signals";
import { async } from "elements-kit/utilities/async";
const id = signal(1);
const fetchTodo = async(() =>
fetch(`https://jsonplaceholder.typicode.com/todos/${id()}`) // tracked
.then((res) => res.json()),
).start(); // re-fetches automatically when id changes
id(2); // triggers a new fetch
```
> **Signals aren't tracked after an `await`**
>
> ```ts
> // ❌ Not reactive — id() is read after an await
> const op = async(async () => {
> await someAsyncSetup();
> const currentId = id(); // not tracked
> });
>
> // ✅ Reactive — id() is read before any await
> const op = async(async () => {
> const currentId = id(); // tracked
> await fetch(`/todos/${currentId}`);
> });
> ```
`run()` is untracked — signals inside the fn do not trigger re-runs. To get reactive reruns with explicit parameters, wrap it in an external `effect`:
```ts
import { effect, signal } from "elements-kit/signals";
const todoId = signal(1);
effect(() => {
fetchTodo.run(todoId()); // re-fetches when todoId changes (tracked by outer effect)
});
```
## Cleanups
Register cleanup logic inside your async function using `onCleanup`. It runs when `stop()` is called or when `start()` re-runs due to a signal change:
```ts
import { onCleanup } from "elements-kit/signals";
const query = async((id: number) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
return fetch(`/api/todos/${id}`, { signal: controller.signal }).then((r) =>
r.json(),
);
}).start();
```
`onCleanup` also works inside `run()` — the cleanup fires when `stop()` is called or when the next `run()` replaces it.
## See also
* [Promise](/primitives/promise) — the underlying `ComputedPromise` / `ReactivePromise` primitive.
* [Data fetching](/examples/data-fetching) — full recipe composing retry, online, window-focus.
* [Signals](/signals) — `onCleanup`, `untracked`.
---
# Retry
Source: https://elements-kit.com/primitives/retry
`retry` wraps an async function and re-runs it on failure.
## retry
Wraps a `() => Promise` with retry logic. Retries up to `attempts` times on failure. The optional `delay` is inserted **between** failures only — not after the final one. Each attempt runs in an effect scope, so `onCleanup` inside the function fires before each retry.
```ts
import { retry } from "elements-kit/utilities/retry";
const fn = retry(
() => fetch("/api/data").then((r) => r.json()),
3, // up to 3 attempts
(n) => n * 500, // 0ms, 500ms, 1000ms between failures
);
await fn(); // retries automatically on failure
```
```ts
function retry(
fn: () => Promise,
attempts: number,
delay?: number | ((attempt: number) => number),
): () => Promise
```
Compose with `async()` for reactive retries:
```ts
import { async } from "elements-kit/utilities/async";
import { retry } from "elements-kit/utilities/retry";
import { onCleanup } from "elements-kit/signals";
const fetchTodo = async((id: number) =>
retry(() => {
const controller = new AbortController();
onCleanup(() => controller.abort()); // aborts before each retry
return fetch(`/api/todos/${id}`, { signal: controller.signal }).then((r) => r.json());
}, 3, 500)(),
).start();
```
***
## See also
* [Async](/primitives/async)
* [Data fetching example](/examples/data-fetching)
---
# Storage
Source: https://elements-kit.com/primitives/storage
Persist state with a signal: read it, write it, and it stays in storage.
## createLocalStorage
Returns a `Signal` persisted to `localStorage`. Writes in other tabs/windows are synchronised automatically via `StorageEvent`.
```ts
import { createLocalStorage } from "elements-kit/utilities/storage";
const theme = createLocalStorage("theme", "light");
theme(); // "light"
theme("dark"); // persisted immediately
```
```ts
function createLocalStorage(
key: string,
initialValue: T,
options?: { serialise?: (v: T) => string; deserialise?: (raw: string) => T },
): Signal
```
***
## createSessionStorage
Same as `createLocalStorage` but scoped to the current tab — no cross-tab sync.
```ts
import { createSessionStorage } from "elements-kit/utilities/storage";
const draft = createSessionStorage("draft", "");
```
```ts
function createSessionStorage(key: string, initialValue: T, options?: StorageOptions): Signal
```
***
## See also
* [Promise](/primitives/promise)
* [Async](/primitives/async)
---
# Form object
Source: https://elements-kit.com/primitives/form-object
`FormObject` turns an `HTMLFormElement` into a structured plain object — and writes one back — using the control `name` to shape the result. It is a static snapshot, like `new FormData()`, with no reactivity or dependencies. Type into the form below and watch the object update.
## Field names shape the object
The `name` attribute is a path. Dots nest objects, numeric segments build arrays, and a trailing `[]` marks an append array.
```ts
import { FormObject } from "elements-kit/utilities/form-object";
// → { user: { name } }
// → { user: { address: { city } } }
// → { tags: [ "…" ] } (explicit index)
// → { colors: [ "…" ] } (append, one per control)
const data = new FormObject(form).toObject();
```
A trailing `[]` always produces an array — a single `colors[]` control gives `["x"]`, and a group with none checked still serializes to `[]`. Use it whenever a field is conceptually a list.
## Reading a form
`toObject()` walks the form's named controls and returns the nested object. It mirrors native form submission: disabled and unchecked controls are dropped, `