Philosophy
How ElementsKit thinks β composable, explicit, framework-optional primitives.
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, () => <App/>) |
| Function component | function Counter(props) { return <div/> } | |
| Class component | class Counter { render() { return <div/> } } | |
| 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.
// ElementsKit β explicit. signal() is reactive, let isn't.
// computed() declares a derivation. onCleanup names the teardown.
function Counter() {
const n = signal(0);
const doubled = computed(() => n() * 2);
const id = setInterval(() => n(n() + 1), 1000);
onCleanup(() => clearInterval(id));
return <div>{() => n()} / {() => doubled()}</div>;
}
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 for the full treatment.
// useSignal β subscribe to any signal or computed.
/** @jsxImportSource react */
import { online } from "elements-kit/utilities/network";
import { useSignal } from "elements-kit/integrations/react";
export function OfflineBanner() {
const connected = useSignal(online);
return connected ? null : <div>You're offline</div>;
}
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.