Core Package · Reactive Core (JS-first)
A signals-based, fine-grained reactive core — signal / computed / effect, async resources, a no-VDOM DOM layer, and a bridge to every wc-bindable tag. Zero runtime dependencies, buildless, standards-first.
シグナルベースの細粒度リアクティブコア。TC39形のAPI、非同期resource、VDOMなしのDOM層、wc-bindableブリッジ。ランタイム依存ゼロ。
<script type="importmap">
{
"imports": {
"@wcstack/signals": "https://esm.run/@wcstack/signals",
"@wcstack/signals/dom": "https://esm.run/@wcstack/signals/dom"
}
}
</script>
wcstack ships two reactive cores that drive the same wc-bindable I/O nodes. Pick the one that matches where your logic lives.
wcstackには同じwc-bindableタグ群を駆動する2つのリアクティブコアがある。ロジックの置き場所で選ぶ。
data-wcsfor / if directivessignal / computed / effect in plain modulesh() DOM, keyed For / Index| API | Signature | Behavior |
|---|---|---|
signal | (initial, equals?) → WriteSignal | get() tracks, peek() reads without tracking, set() updates. Equality defaults to Object.is |
computed | (fn, equals?) → ReadSignal | Lazy, memoized; equality short-circuits. Re-tracks on every run; throws on circular dependencies |
effect | (fn) → EffectHandle | Runs immediately, then on coalesced microtasks. fn may return a cleanup; handle.dispose() stops it |
createRoot | (fn(dispose)) → T | Fresh ownership scope; teardown disposes everything created inside |
onCleanup | (fn) → void | Registers teardown with the current owner |
flushSync | () → void | Drains queued effects synchronously, right now |
import { signal, computed, effect } from "@wcstack/signals";
const count = signal(0);
const doubled = computed(() => count.get() * 2);
effect(() => {
console.log(`count=${count.peek()}, doubled=${doubled.get()}`);
});
count.set(1); // effect re-runs → "count=1, doubled=2"
Pull-validated three-color marking: a write marks direct observers dirty and transitive ones check. Effects run coalesced on a microtask; a computed that returns an equal value does not propagate downstream.
書き込みは直接の観測者をdirty、間接をcheckにマーク。エフェクトはマイクロタスクで合流実行。等値のcomputedは下流に伝播しない。
Ownership = lifecycle. createRoot and effects collect the disposers of everything created within them. Tearing down a subtree disposes its effects, listeners, and resources — no leaks.
所有権がライフサイクル。サブツリーを破棄すれば配下のエフェクト・リスナー・リソースも破棄される。
resource() — one value, switchMap semanticsReturns { value, loading, error, dispose }. The source(args, signal) receives an AbortSignal; when the reactive args change, the in-flight run is aborted and restarted.
argsが変わると実行中のリクエストは中断されて再実行(switchMap)。
import { signal, resource } from "@wcstack/signals";
const id = signal(1);
const user = resource(
async (userId, signal) =>
(await fetch(`/api/users/${userId}`, { signal })).json(),
{ args: () => id.get() },
);
id.set(2); // aborts the in-flight request, starts fresh
streamResource() — continuous flows, foldedFolds async iterables and ReadableStreams into a reactive value. Returns { value, status, error, dispose } with status one of "idle" | "active" | "done" | "error". The tag-side counterpart is $streams in <wcs-state>.
非同期イテラブルやReadableStreamをリアクティブな値に畳み込む。タグ側の対応物が<wcs-state>の$streams。
import { streamResource } from "@wcstack/signals";
const log = streamResource(
(args, signal) => openLogStream(signal),
{
fold: (acc, chunk) => [...(acc ?? []), chunk],
initial: [],
},
);
// log.value / log.status / log.error
Cooperative cancellation is a hard contract: your source must honor the AbortSignal. And there is no backpressure — bound the fold result yourself for infinite streams.
sourceはAbortSignalを必ず尊重すること。バックプレッシャはないので、無限ストリームでは畳み込み結果を自分で制限する。
Imported from @wcstack/signals/dom (which re-exports the core). h(tag, props, ...children) builds real DOM once; function or signal props and children wire targeted effects — only that binding updates. No VDOM reconciler ships.
h()は本物のDOMを一度だけ構築。関数/シグナルのprops・childrenが対象限定のエフェクトを張り、その箇所だけが更新される。VDOMなし。
| API | Notes |
|---|---|
h(tag, props?, ...children) | JSX-compatible factory; tag is a string, Component, or Fragment |
render(child, container) | Appends into container, resolving fragments / arrays / reactives |
Fragment | Groups children without a wrapper element |
SignalsElement | Abstract HTMLElement base; implement render(), optionally getMountPoint() |
createSignalsElement() | Builds the base class at call time — SSR-safe, clear error if no DOM |
import { signal, computed, h, SignalsElement } from "@wcstack/signals/dom";
class SignalCounter extends SignalsElement {
count = signal(0);
doubled = computed(() => this.count.get() * 2);
render() {
return h("div", { class: "counter" },
h("button", { onClick: () => this.count.set(this.count.peek() - 1) }, "−"),
h("output", null, () => String(this.count.get())),
h("button", { onClick: () => this.count.set(this.count.peek() + 1) }, "+"),
h("span", { class: "muted" }, () => `×2 = ${this.doubled.get()}`),
);
}
}
customElements.define("signal-counter", SignalCounter);
For / Index #For(list, each, options?) reconciles by key — rows are reused and moved when items shuffle. Index(list, each) keys by position — each slot's row is stable, the item arrives as a getter. Bare reactive children rebuild wholly; use these for lists.
Forはキーで照合し行を再利用・移動。Indexは位置で照合し、要素はゲッターで届く。素のリアクティブchildは全再構築されるため、リストには必ずこれらを使う。
import { signal, h, For, Index } from "@wcstack/signals/dom";
const todos = signal([{ id: 1, text: "a" }, { id: 2, text: "b" }]);
// For — keyed by id; each(item, index: () => number)
h("ul", null,
For(todos, (t, index) => h("li", null, () => `${index()}: ${t.text}`),
{ key: (t) => t.id }),
);
// Index — position-keyed; each(item: () => T, index: number)
const nums = signal([10, 20, 30]);
h("ul", null,
Index(nums, (n) => h("li", null, () => String(n() * 2))),
);
h has the classic JSX factory shape — no JSX ships by default, but transpilation is one config away. Reactive bits stay thunks or signals ({() => count.get()}). Not implemented: ref, context, controlled inputs, and the key attribute — use For / Index for keyed lists.
hはクラシックJSXファクトリ形。リアクティブな箇所はサンク/シグナルのまま書く。ref・context・key属性は非対応(リストはFor/Index)。
// tsconfig.json
{
"compilerOptions": {
"jsx": "react",
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment"
}
}
import { h, Fragment, signal, render } from "@wcstack/signals/dom";
const count = signal(0);
const view = (
<button onClick={() => count.set(count.peek() + 1)}>
count: {() => count.get()}
</button>
);
render(view, document.body);
bindNode #bindNode(target, descriptor?) bridges any wc-bindable element — all 30+ wcstack I/O nodes — into signals. IO is the node, reactivity is the core: the element has no signal awareness.
bindNodeがwc-bindable要素をシグナルに橋渡しする。要素側はシグナルを一切知らない。
| Member | Purpose |
|---|---|
bound.signals.<prop>.get() | Read-only signal snapshots of observable properties |
bound.on("prop", { fold?, initial? }) | Event-token streams (latest-by-default fold) |
bound.set("input", value) | Imperative input write |
bound.bindInput("input", sig) | Reactive mirror with same-value guard |
bound.command("cmd", ...args) | Imperative command invocation |
bound.bindCommand("cmd", trigger, mapArgs?) | Fire a command when a trigger changes |
bound.dispose() | Detach all listeners / effects; adapter becomes inert |
nodeSource(bound, run, { abort? }) wraps a node as a resource source, wiring the resource's AbortSignal to the node's cancel command (default "abort").
<wcs-fetch>import { signal, computed, effect, createRoot, bindNode, h, render, For }
from "@wcstack/signals/dom";
const bound = bindNode(fetchEl);
const query = signal("");
const people = computed(() => bound.signals.value.get() ?? []);
createRoot(() => {
effect(() => {
const q = query.get().trim();
bound.set("url", q ? `/api/people?q=${encodeURIComponent(q)}` : "/api/people");
});
render(
h("div", null,
h("input", { type: "search", onInput: (e) => query.set(e.target.value) }),
h("p", null, () => bound.signals.loading.get() ? "Loading…"
: `${people.get().length} result(s)`),
h("ul", null, For(() => people.get(),
(p) => h("li", null, p.name), { key: (p) => p.id })),
),
document.getElementById("search-app"),
);
});
NodeShapeimport { bindNode, NodeShape } from "@wcstack/signals";
interface FetchShape extends NodeShape {
signals: { value: Person[]; loading: boolean };
inputs: { url: string };
commands: { fetch: (url: string) => Promise<Person[]>; abort: () => void };
}
const bound = bindNode<FetchShape>(fetchEl, FetchCore.wcBindable);
bound.signals.value.get(); // ReadSignal<Person[]>
bound.set("url", "/api/people"); // string enforced
bound.command("fetch", "/api");
globalThis.reportError (dispatching the window error event) or console.error. It is never re-thrown into unrelated code — and never silently dropped.get() / peek() propagate to the caller.MAX_FLUSH_ITERATIONS) — the signature of an effect writing a tracked signal with an ever-changing value.DisposedError: mutating BoundNode methods throw after dispose(). Detect with the brand-based, realm-safe isDisposedError(err).try {
bound.command("abort");
} catch (err) {
if (!isDisposedError(err)) throw err;
}
Set globalThis.__WCS_DEV__ = true before your code runs to enable diagnostics:
コード実行前に__WCS_DEV__を有効にすると診断が出る。
| Code | Condition | Fix |
|---|---|---|
DUPLICATE_KEY | For given duplicate keys | Ensure keys are unique per item |
NON_PRIMITIVE_KEY | For over objects without a key option | Pass { key: item => item.id } |
NULLISH_KEY | Key resolves to null / undefined / NaN | Provide a stable unique key |
UNOWNED_EFFECT | effect(...) with no owner | Wrap in createRoot or SignalsElement |
UNOWNED_INSERT | Reactive child with no owner | Mount under createRoot / SignalsElement |
ORPHAN_CLEANUP | onCleanup(...) outside an owner | Call inside an effect / createRoot / SignalsElement |
REACTIVE_CYCLE | Runaway-cycle guard triggered | Find the effect writing a value it depends on |
| Surface | Status |
|---|---|
Reactive core (signal, computed, effect, createRoot, onCleanup, flushSync) | Stable |
Error contract (DisposedError, isDisposedError) | Stable |
Resources (resource, streamResource) | Stable |
wc-bindable adapter (bindNode, nodeSource) | Evolving |
DOM layer (h, render, For, Index, SignalsElement) | Evolving |
Development mode (__WCS_DEV__) | Experimental |
Within v1.x, stable APIs keep backward compatibility; evolving APIs may adjust in minor releases.
queueMicrotask, AbortController, WeakMap / WeakSet; ReadableStream + async iteration for streamResource only.. entry is fully non-DOM; ./dom evaluates without a DOM (SSR pre-pass safe) but its DOM-touching surface needs DOM globals when used — createSignalsElement() gives a clear error otherwise.コアは非DOM環境で完全動作。./domエントリの評価自体はSSRセーフ。v1にはSSRハイドレーション・深いリアクティビティ・バックプレッシャはない。
npm install @wcstack/signals
Entry points: @wcstack/signals (core, resources, adapter) and @wcstack/signals/dom (h, render, Fragment, For, Index, SignalsElement, plus re-exported core).