@wcstack/signals

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ブリッジ。ランタイム依存ゼロ。

v1.22.6 @wcstack/signals core ≈2.5 KB gz dom +≈2.1 KB gz 0 dependencies MIT
index.htmlBuildless via import map
<script type="importmap">
{
  "imports": {
    "@wcstack/signals": "https://esm.run/@wcstack/signals",
    "@wcstack/signals/dom": "https://esm.run/@wcstack/signals/dom"
  }
}
</script>

Which core? #

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つのリアクティブコアがある。ロジックの置き場所で選ぶ。

<wcs-state>

HTML-first

  • State declared in a tag; bindings are path strings in data-wcs
  • Mustache syntax, 40 filters, for / if directives
  • SSR + hydration, in-page DevTools integration
  • Best when the app is written in HTML
Read the state docs →
@wcstack/signals

JS-first — this page

  • signal / computed / effect in plain modules
  • Fine-grained h() DOM, keyed For / Index
  • ≈2.5 KB core, TC39-shaped API
  • Best when logic lives in TypeScript

The reactive core #

APISignatureBehavior
signal(initial, equals?) → WriteSignalget() tracks, peek() reads without tracking, set() updates. Equality defaults to Object.is
computed(fn, equals?) → ReadSignalLazy, memoized; equality short-circuits. Re-tracks on every run; throws on circular dependencies
effect(fn) → EffectHandleRuns immediately, then on coalesced microtasks. fn may return a cleanup; handle.dispose() stops it
createRoot(fn(dispose)) → TFresh ownership scope; teardown disposes everything created inside
onCleanup(fn) → voidRegisters teardown with the current owner
flushSync() → voidDrains queued effects synchronously, right now
counter.tsBasic usage
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"

Scheduling model

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.

所有権がライフサイクル。サブツリーを破棄すれば配下のエフェクト・リスナー・リソースも破棄される。

Async resources #

resource() — one value, switchMap semantics

Returns { 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)。

user.tsresource
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, folded

Folds 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。

log.tsstreamResource
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を必ず尊重すること。バックプレッシャはないので、無限ストリームでは畳み込み結果を自分で制限する。

The DOM layer #

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なし。

APINotes
h(tag, props?, ...children)JSX-compatible factory; tag is a string, Component, or Fragment
render(child, container)Appends into container, resolving fragments / arrays / reactives
FragmentGroups children without a wrapper element
SignalsElementAbstract HTMLElement base; implement render(), optionally getMountPoint()
createSignalsElement()Builds the base class at call time — SSR-safe, clear error if no DOM

A reactive custom element

signal-counter.tsSignalsElement
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);

Keyed lists: 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は全再構築されるため、リストには必ずこれらを使う。

lists.tsFor / Index
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))),
);

JSX (opt-in) #

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 + app.tsxJSX setup
// 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);

Binding I/O nodes: 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要素をシグナルに橋渡しする。要素側はシグナルを一切知らない。

MemberPurpose
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").

Example: a search UI over <wcs-fetch>

search.tsbindNode + h + For
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"),
  );
});

Typed NodeShape

typed.tsNodeShape
import { 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");

Error handling #

teardown.tsisDisposedError
try {
  bound.command("abort");
} catch (err) {
  if (!isDisposedError(err)) throw err;
}

Development mode #

Set globalThis.__WCS_DEV__ = true before your code runs to enable diagnostics:

コード実行前に__WCS_DEV__を有効にすると診断が出る。

CodeConditionFix
DUPLICATE_KEYFor given duplicate keysEnsure keys are unique per item
NON_PRIMITIVE_KEYFor over objects without a key optionPass { key: item => item.id }
NULLISH_KEYKey resolves to null / undefined / NaNProvide a stable unique key
UNOWNED_EFFECTeffect(...) with no ownerWrap in createRoot or SignalsElement
UNOWNED_INSERTReactive child with no ownerMount under createRoot / SignalsElement
ORPHAN_CLEANUPonCleanup(...) outside an ownerCall inside an effect / createRoot / SignalsElement
REACTIVE_CYCLERunaway-cycle guard triggeredFind the effect writing a value it depends on

Stability & support #

SurfaceStatus
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.

Browser support & SSR

コアは非DOM環境で完全動作。./domエントリの評価自体はSSRセーフ。v1にはSSRハイドレーション・深いリアクティビティ・バックプレッシャはない。

Install

terminalnpm
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).