Core Package · Reactive Core (HTML-first)
Reactive state management with declarative data binding. The only contract between UI and state is a path string — user.name, cart.items.*.subtotal. No virtual DOM, no build step, no hooks.
パス文字列だけがUIと状態の契約。仮想DOMなし、ビルドなし、フックなし。ES Proxyによる純粋なリアクティビティ。
<script type="module" src="https://esm.run/@wcstack/state/auto"></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 / IndexA <wcs-state> element manages reactive state. data-wcs attributes bind DOM to paths like user.name or cart.items.*.subtotal. The component's JavaScript never references the DOM; the HTML alone describes every data dependency.
<wcs-state>が状態を管理し、data-wcs属性がDOMをパスに結びつける。JavaScriptはDOMを一切参照しない。
Reactivity is driven by path assignment. Assign to a path via this["path.to.value"] or a top-level property — deep in-place mutation is not detected.
更新は「パスへの代入」で検出される。オブジェクトの深い直接変更は検出されない。
this.count = 10; // ✓ detected
this["user.name"] = "Bob"; // ✓ detected
this.user.name = "Bob"; // ✗ NOT detected — no path assignment
// Arrays: non-destructive reassignment
this.items = this.items.concat({ id: 4 }); // ✓
this.items = this.items.toSpliced(0, 1); // ✓
this.items.push({ id: 4 }); // ✗ in-place mutation not detected
These are not conditional suspicions — the undetected shapes always drop the update. As of v1.31.0 the linter treats all three (wcs/nested-assign, wcs/array-mutation, wcs/array-index-assign) as errors, so wcs-validate fails a build containing any. And a bound path that provably does not resolve (user.nmae) warns once at binding time with wcs/binding-path-missing and a did-you-mean — the check under-approximates, so absence of a warning proves nothing.
これらは条件つきの疑いではない — 検出されない形は常に更新を落とす。v1.31.0からlinterは3つすべて(wcs/nested-assign・wcs/array-mutation・wcs/array-index-assign)をerrorとして扱い、1つでも含むビルドはwcs-validateで落ちる。また、確実に解決しないバインド済みパス(user.nmae)はバインド時にwcs/binding-path-missingとdid-you-meanつきで一度警告される — 検査は過小近似なので、警告がないことは何の証明にもならない。
state="id" → a <script type="application/json" id> elementsrc="*.json" or src="*.js" → external filejson='{ }' → inline JSON attribute<script type="module"> → export defaultsetInitialState() → programmatic APImount)There is one state tree per root. To split state across modules, mount a volume: its data is grafted onto the root tree at the mount path, and bindings read it by prefix. This is mount /dev/sdb1 /mnt/data, not a second namespace. The root <wcs-state> is required (it may be empty), load order does not matter — a volume connected before the root is grafted when the root registers — and mount paths are static (*, $, #, @ are rejected). A volume carries its own getters, $watch, $listKeys, $updatedCallback and lifecycle callbacks, all relative to its mount point.
stateの木はルートごとに1本。モジュールに分けるときはボリュームをマウントする。データはルートの木のマウントパスに接ぎ木され、バインディングは接頭辞付きのパスで読む。第二の名前空間ではなくmount /dev/sdb1 /mnt/dataである。ルートの<wcs-state>は必須(空でよい)で、ロード順は問わない — ルートより先に接続されたボリュームはルート登録時に接ぎ木される — マウントパスは静的(* $ # @は不可)。ボリュームは自分のgetter・$watch・$listKeys・$updatedCallback・ライフサイクルを、すべてマウント点からの相対で持つ。
<wcs-state src="./app.js"></wcs-state>
<wcs-state mount="cart" src="./cart.js"></wcs-state>
<div data-wcs="textContent: cart.total"></div>
Migrating from v1 named state. name= and path@name were removed in v2: name="cart" becomes mount="cart", total@cart becomes cart.total, and @default simply drops. The runtime fails fast on name= and treats @ in a path as a parse error, both printing the replacement, and wcs/named-state-deprecated lists every remaining site as a lint error. One volume caveat: $streams raises on a volume, and $commandTokens / $eventTokens / $on belong on the root — those are the only declarations that are not a plain rename.
v1のnamed stateからの移行。 name=とpath@nameはv2で削除された。name="cart"はmount="cart"に、total@cartはcart.totalに、@defaultは単に外す。ランタイムはname=で即座に失敗し、パス中の@はparse errorになり、どちらも置き換え先を表示する。残った箇所はwcs/named-state-deprecatedがlintのerrorとして列挙する。ボリュームでの注意は1つ — $streamsはボリュームでは不可、$commandTokens / $eventTokens / $onはルートに置く。単純なリネームで済まないのはこの4つだけ。
export default {
async $connectedCallback() { // on connect (awaited)
const res = await fetch("/api/items");
this.items = await res.json();
},
$disconnectedCallback() { // on disconnect (sync)
clearInterval(this.timer);
},
$updatedCallback(paths, indexes) { // after updates (async, not awaited)
console.log("updated:", paths);
},
$errorCallback(error, { path }) { // a binding failed to apply (root-only, not awaited)
this.loadError = `${path}: ${error.message}`;
}
};
$errorCallback(error, info) (v2.2.0) is the in-page error boundary for bindings. When applying a binding throws — a path getter or filter threw, a structural directive failed — the failure is isolated (the rest of the batch still applies) and, without the hook, reported with console.error. Declare it on the root state and the report comes to you instead: info is { path, bindingType, node } with the path as written in data-wcs, this is the writable state proxy, and the hook runs once per failed binding after $updatedCallback, is not awaited, and has its own exceptions isolated. Root-only (a volume declaring it is ignored); DevTools still receives state:binding-apply-error.
$errorCallback(error, info)(v2.2.0)はバインディングのページ内エラー境界。バインディング適用時のthrow — パスgetterやフィルタの例外、構造ディレクティブの失敗 — は隔離され(バッチの残りは適用される)、フックが無ければconsole.errorに報告される。ルートstateに宣言すればその報告が届く: infoは{ path, bindingType, node }でpathはdata-wcsに書かれたまま、thisは書き込み可能なstateプロキシ、失敗したバインディングごとに$updatedCallbackの後に1回走り、awaitされず、自身の例外も隔離される。ルート専用(ボリュームでの宣言は無視される)。DevToolsは変わらずstate:binding-apply-errorを受け取る。
Computed properties are defined with dot-path string keys, flat at the top level, regardless of how deep the data nests. The wildcard * abstracts the loop index away.
算出プロパティはドットパス文字列のキーで、データの深さに関係なくフラットに定義する。ワイルドカード*がインデックスを抽象化する。
export default {
users: [
{ id: 1, firstName: "Alice", lastName: "Smith" },
{ id: 2, firstName: "Bob", lastName: "Jones" }
],
get "users.*.fullName"() {
return this["users.*.firstName"] + " " + this["users.*.lastName"];
},
// Path setters — split a write back into its parts
set "users.*.fullName"(value) {
const [first, ...rest] = value.split(" ");
this["users.*.firstName"] = first;
this["users.*.lastName"] = rest.join(" ");
},
// Top-level aggregation over a wildcard
get totalUsers() {
return this.$getAll("users.*.id", []).length;
}
};
get "categories.*.items.*.label"() {
return this["categories.*.name"] + " / " + this["categories.*.items.*.name"];
}
$recursion)A path burns its depth into the string, but a tree’s depth belongs to the data. v2.3.0: declare where the shape repeats — an anchor and the sub-path that descends one level — and ** stands for “however deep this is”. One getter then covers every depth, and each depth’s accessor is materialized the first time it is read.
パスは深さを文字列に焼き付けるが、木の深さはデータに属する。v2.3.0: 形が繰り返される場所 — アンカーと1段降りる部分パス — を宣言すれば、**が「ここが何段であろうと」を表す。getter 1つがすべての深さを覆い、各深さのアクセサは最初に読まれたときに実体化される。
export default {
$recursion: { "nodes.*": "children.*" }, // anchor → repeating sub-path
nodes: [ { value: 1, children: [ { value: 10, children: [] } ] } ],
// `**` is bound to the depth being evaluated; the index-omitted
// $getAll reads exactly the direct children of that node.
get "nodes.**.total"() {
return this["nodes.**.value"]
+ this.$getAll("nodes.**.children.*.total").reduce((a, b) => a + b, 0);
},
// `[]` unions every depth — depth-first, pre-order, ascending index
get treeTotal() {
return this.$getAll("nodes.**.value", []).reduce((a, b) => a + b, 0);
},
clearSelection() {
this.$setAll("nodes.**.selected", [], false); // broadcast to every node
}
};
Bound or unioned is decided by context, the same split * has between “the current row” and “every row”: a getter key and reads inside it are bound to the depth being evaluated, $getAll with indexes omitted stays at that depth, and $getAll(path, []) unions every depth. The omitted form is what makes an aggregate fold its own subtree exactly once; unioning an aggregate from outside double-counts, and that is not decidable from the path string, so no diagnostic claims to catch it. Bound reads need a depth, so they resolve only inside a recursive getter, a row getter under the anchor, or a handler bound to such a row — elsewhere it is wcs/recursion-context.
束縛か連結かは文脈が決める。*の「現在の行」と「すべての行」と同じ対比だ: getterのキーとその内側の読みは評価中の深さに束縛され、インデックスを省いた$getAllはその深さに留まり、$getAll(path, [])は全深さを連結する。集計が自分の部分木をちょうど1回だけ畳み込むのは省略形のおかげ。外から集計値を連結すると二重計上になるが、これはパス文字列からは決定できないので、捕まえると称する診断は無い。束縛形の読みは深さを必要とするため、再帰getter・アンカー配下の行getter・その行に束縛されたハンドラの中でのみ解決する。それ以外ではwcs/recursion-context。
** is authoring notation only — PathInfo, the dependency graph, $1…$n, $resolve and the list diff only ever see ordinary fixed-arity paths. What cannot be expressed that way is refused rather than reinterpreted, under the wcs/recursion-* codes the linter shares: a recursive getter is read-only (write what it derives from), a broadcast at the structure itself is refused, ** in markup / $watch / $listKeys / $resolve is wcs/recursion-unsupported, and this version takes exactly one self-recursive anchor per state, a tree as input (a shared child array or a cycle is named) and 128 wildcard levels. $recursion and ** getters are root-only. Rendering a tree stays with a self-referential component: ** cannot appear in markup, and inside each scope only one level of path is ever used.
**は記述上の記法にすぎない — PathInfo・依存グラフ・$1…$n・$resolve・リスト差分が見るのは、常に固定アリティの普通のパスだ。そう表現できないものは再解釈されずに拒まれる。コードはlinterと共通のwcs/recursion-*: 再帰getterは読み取り専用(導出元に書くこと)、構造そのものへのブロードキャストは拒否、マークアップ・$watch・$listKeys・$resolve中の**はwcs/recursion-unsupported。このバージョンが受け付けるのはstateごとに自己再帰アンカー1つ、入力は木(子配列の共有や循環は名指しされる)、ワイルドカードは128段まで。$recursionと**getterはルート専用。木の描画は従来どおり自己参照コンポーネントが担う: **はマークアップに書けず、各スコープの中で使うパスは常に1段だけだ。
| API | Purpose |
|---|---|
this.$getAll("path.*", []) | Collect all values for a wildcard path |
this.$resolve("path.*", [i], value?) | Read / write at an explicit index |
this.$setAll("path.*", [], value) | v1.32.0: write every matching address in place — broadcast, mapper (cur, ...i) => next, or per-address array with { spread: true }. Keeps the array (list indexes, row caches, render diff survive, unlike .map() reassignment); undefined means “skip this address”, null clears |
this.$postUpdate("path") | Manually trigger an update notification |
this.$trackDependency(path) | Register a dependency for cache invalidation |
this.$untrackDependency(fn) | Read values without registering dependencies |
this.$1, this.$2, … | Loop iteration index (0-based value, 1-based naming) |
Since v1.31.0 index arity is checked: $resolve requires the index count to match the path’s * count exactly, and $getAll treats it as an upper bound (fewer is a legitimate prefix meaning “expand the rest”). Surplus indexes used to be silently dropped, returning a plausible-looking wrong value; both now throw wcs/index-arity — the same code the linter and the IDE report statically, alongside wcs/wildcard-rank (a path’s * count or $N exceeding the enclosing for nesting) and wcs/getter-cycle (which names the getters in the cycle).
v1.31.0からインデックスのアリティが検査される。$resolveはインデックス数がパスの*数と完全一致することを要求し、$getAllは上限として扱う(少ない分は「残りを展開」という正当なプレフィクス)。余分なインデックスは以前は黙って捨てられ、もっともらしい間違った値が返っていた。いまはどちらもwcs/index-arityをthrowする — linterとIDEが静的に報告するのと同じコードで、wcs/wildcard-rank(パスの*数や$Nが囲むforの深さを超える)と、サイクル中のgetter名を挙げるwcs/getter-cycleも並ぶ。
A getter’s cache is invalidated only through the dependency graph, and the graph records only what was read through this. A getter reading Date.now(), the DOM, or a module variable therefore keeps its first value forever — with no warning. Read only through this; when an untracked input must participate, put it into state and assign to it, or use $trackDependency / $postUpdate / $untrackDependency. Getters that throw are not swallowed: the exception surfaces where the getter was evaluated.
getterのキャッシュは依存グラフ経由でのみ無効化され、グラフはthis越しの読み取りしか記録しない。Date.now()・DOM・モジュール変数を読むgetterは、警告なしに最初の値を永遠に保持する。読むのはthis越しだけにする。追跡外の入力を関与させたいときは、stateに入れて代入するか、$trackDependency / $postUpdate / $untrackDependencyを使う。throwするgetterは握りつぶされず、評価された場所で例外が表面化する。
Getters are lazy, and exactly three things create demand: a live DOM binding (demand disappears with the element), a $watch declaration (headless), and a $streams args function. $updatedCallback is not a root — it reports what the bindings did. Logic that must not depend on what is rendered belongs on $watch or args; $updatedCallback testing a path nothing binds is detected statically as wcs/updated-callback-unbound (v1.31.0).
getterは遅延評価で、需要を生むものはちょうど3つ。ライブなDOMバインディング(要素が消えれば需要も消える)、$watch宣言(ヘッドレス)、$streamsのargs関数。$updatedCallbackはrootではない — バインディングが行ったことを報告するだけだ。描画に依存してはならないロジックは$watchかargsに置く。何もバインドしていないパスを$updatedCallbackで調べる形はwcs/updated-callback-unboundとして静的に検出される(v1.31.0)。
data-wcs #General form — multiple bindings separated by ;:
property[#modifier]: path[@state][|filter[|filter(args)...]]
<div data-wcs="textContent: count; class.over: count|gt(10)"></div>
| Property | Behavior |
|---|---|
value | Two-way for <input>, <select>, <textarea> |
checked | Two-way for checkbox / radio |
textContent / text | DOM text |
html | innerHTML |
class.NAME | CSS class toggle |
style.PROP | CSS property |
attr.NAME | HTML attribute (SVG-safe) |
radio | Radio group binding |
checkbox | Checkbox array binding |
onclick, on* | Event handlers |
<input data-wcs="value#ro: path"> <!-- read-only, disables two-way -->
<button data-wcs="onclick#prevent: handler"> <!-- preventDefault() -->
<div data-wcs="onclick#stop: handler"> <!-- stopPropagation() -->
<select data-wcs="value#onchange: path"> <!-- use 'change' event -->
<my-el data-wcs="value#init=element: path"> <!-- element-owned authority -->
<my-el data-wcs="value#sync=connect: path"> <!-- defer snapshot until DOM connect -->
Two-way binding is automatic for <input> (all types), <select>, <textarea>, checkbox and radio. Use #ro to disable it, #onchange to switch the event.
双方向バインディングはフォーム要素で自動。#roで無効化、#onchangeでイベント切替。
With enableMustache: true (the default), text interpolation works anywhere — internally converted to comment-based bindings.
<p>Hello, {{ user.name }}!</p>
<p>Count: {{ count|locale }}</p>
...)Wire all properties and inputs of a wc-bindable element in one line. Expands to individual bindings per member; later bindings win; undefined skips the write (only null and defined values are written).
wc-bindable要素の全メンバーを1行で結線。後勝ちの上書き、undefinedは書き込みスキップ。
<wcs-fetch data-wcs="...: usersFetch"></wcs-fetch>
<!-- Inside a loop -->
<template data-wcs="for: storesFetches">
<wcs-fetch data-wcs="...: storesFetches.*"></wcs-fetch>
</template>
<!-- Last-wins override -->
<wcs-fetch data-wcs="...: usersFetch; status: alternateStatus"></wcs-fetch>
for<template data-wcs="for: users">
<div data-wcs="textContent: users.*.name"></div> <!-- full path -->
<div data-wcs="textContent: .name"></div> <!-- dot shorthand -->
</template>
| Shorthand | Expands to |
|---|---|
.name | users.*.name |
. | users.* (current element) |
.name|uc | users.*.name|uc (filters preserved) |
.name@state | users.*.name@state (state name preserved) |
<template data-wcs="for: regions">
<template data-wcs="for: .states">
<span data-wcs="textContent: .name"></span>
</template>
</template>
Lists use a value-based diff: elements are matched by value and DOM nodes are reused for unchanged items. No key attribute is needed, because non-destructive array methods (toSorted, toReversed, filter, with) preserve element references — sorting and filtering are keyed by construction.
リストは値ベースの差分。変化のない要素のDOMノードは再利用される。toSorted / toReversed / filter / with は要素の参照を保つため、key属性は不要。
$listKeys — identity for refetched rowsThe exception, addressed in v1.26.0, is data that arrives as freshly created objects — fetch(...).json(), a WebSocket snapshot, JSON.parse from storage. Nothing matches by reference, so every row is rebuilt and the DOM state the bindings do not own goes with it: focus, an in-flight IME composition, <details> open state, inner scroll, <canvas> contents. Declare a key and rows are matched across the refresh instead.
例外は毎回新しいオブジェクトとして届くデータ(fetch(...).json()、WebSocketのスナップショット、ストレージからのJSON.parse)。参照が一致しないため全行が作り直され、バインディングが所有していないDOM状態 — フォーカス、変換中のIME入力、<details>の開閉、内側のスクロール、<canvas>の内容 — が失われる。キーを宣言すれば、リフレッシュを越えて行が突き合わせられる。
export default {
items: [],
$listKeys: {
"items": "id", // field name
"items.*.children": (row) => row.uid, // or a function, for composite keys
},
async load() {
// Every row object is new, but rows are matched by id: the DOM is reused
// and only the fields that actually differ are written.
this.items = await (await fetch("/api/items")).json();
},
};
null. The stored array is rebuilt from the matched row objects, so this.items !== theArrayYouAssigned afterwards.if / elseif / else<template data-wcs="if: count|gt(0)">
<p>Positive</p>
</template>
<template data-wcs="elseif: count|lt(0)">
<p>Negative</p>
</template>
<template data-wcs="else:">
<p>Zero</p>
</template>
A structural binding — for, if, elseif, else — must be the only binding in its data-wcs; sharing the attribute with anything else raises [wcs/template-syntax]. Put other bindings on elements inside the template. The lint CLI checks the same shape as of v1.29.0.
構造バインディング(for・if・elseif・else)は、そのdata-wcsの中で唯一のバインディングでなければならない。他のバインディングと共有すると[wcs/template-syntax]が投げられる。他のバインディングはテンプレート内の要素に書く。lint CLIもv1.29.0から同じ形を検査する。
Chain with |: price|mul(1.1)|round(2)|locale(ja-JP)
パイプで連結できる。
eq(v)ne(v)notlt(n)le(n)gt(n)ge(n)inc(n)dec(n)mul(n)div(n)mod(n)absclamp(min,max)fix(n)round(n?)floorceillocale(loc?)percent(n?)unit(u)uclccaptrimslice(n)substr(s,l)pad(n,c?)rep(n)revtruncate(n,sfx?)join(sep?)intfloatbooleannumberstringnulldate(loc?)time(loc?)datetime(loc?)ymd(sep?)hms(sep?)truthyfalsydefaults(v)The default locale is <html lang> as of v1.32.0 (breaking; it was 'en'). The four locale-dependent filters — locale, date, time, datetime — read config.locale, which now defaults to the page’s declared language, falling back to 'en'. An explicit bootstrapState({ locale }) still wins, and per-call overrides (price|locale(fr-FR)) are fixed at bind time. Changing config.locale after render updates nothing — set <html lang> in the markup, before the page renders.
既定ロケールはv1.32.0から<html lang>(破壊的変更。従来は'en')。ロケール依存の4フィルタ — locale・date・time・datetime — が読むconfig.localeの既定がページの宣言言語になり、なければ'en'に落ちる。明示的なbootstrapState({ locale })が常に勝ち、per-callの上書き(price|locale(fr-FR))はバインド時に固定される。描画後にconfig.localeを変えても何も更新されない — <html lang>はマークアップに、ページが描画される前に書く。
Six arrived in v1.27.0 — abs, clamp(min,max), unit(u), join(sep?), truncate(n,suffix?) and hms(sep?) — picked so presentation stays on the wire instead of leaking into state. The chain they were designed around: style.height: cpu|clamp(0,100)|fix(0)|unit(%). unit deliberately accepts the strings that fix/percent return, and passes null/undefined through untouched so “undefined skips the write, null clears” survives the filter. Also fixed in v1.27.0: argument trimming is outside-quotes only, so pad(5, ' ') and join(' / ') finally mean what they say.
v1.27.0で6つ追加 — abs・clamp(min,max)・unit(u)・join(sep?)・truncate(n,suffix?)・hms(sep?)。表示の都合をstateに持ち込まず、バインディングの上で済ませるための選定で、設計の起点はstyle.height: cpu|clamp(0,100)|fix(0)|unit(%)というチェーン。unitはfix/percentが返す文字列を意図的に受け入れ、null/undefinedは素通しするので「undefinedは書き込みスキップ、nullはクリア」の意味論がフィルタを通っても保たれる。同じくv1.27.0で、引数のトリムはクォートの外側だけになり、pad(5, ' ')やjoin(' / ')が書いたとおりに動くようになった。
Handler signature: handler(event, ...listIndexes) — loop indexes arrive as extra arguments.
ハンドラにはイベントに続いてループのインデックスが渡される。
<button data-wcs="onclick: handleClick">Click</button>
<form data-wcs="onsubmit#prevent: handleSubmit">...</form>
handleClick(event) { console.log("clicked"); }
removeItem(event, index) {
this.items = this.items.toSpliced(index, 1);
}
Pub/sub channels for invoking element methods from state. Declare channels in $commandTokens, emit arguments with this.$command.<name>.emit().
状態から要素のメソッドを呼ぶpub/subチャンネル。
export default {
$commandTokens: ["fetchUsers", "refreshOrders"],
onClickFetch() {
this.$command.fetchUsers.emit("/api/users", { method: "GET" });
}
};
<!-- Subscribe a wc-bindable command to the token -->
<wcs-fetch data-wcs="command.fetch: $command.fetchUsers"></wcs-fetch>
<!-- Or emit the token directly from an event -->
<button data-wcs="onclick: $command.refreshList">Refresh</button>
| Token API | Purpose |
|---|---|
emit(...args) | Invoke subscribed methods; returns array of results |
subscribe(fn) | Add subscriber; returns unsubscribe function |
unsubscribe(fn) | Remove subscriber |
name / size | Token name / current subscriber count |
The dual of command tokens: elements fire events, state receives them in $on. Signature is (state, event, ...listIndexes) — state is the first argument, not this.
コマンドトークンの双対。要素のイベントを状態が$onで受け取る。第一引数がstate。
export default {
$eventTokens: ["userCreated", "createFailed"],
$on: {
userCreated(state, event) {
state.users = state.users.concat(event.detail);
},
createFailed(state, event) {
state.error = event.detail;
}
}
};
<my-form data-wcs="eventToken.created: userCreated; eventToken.error: createFailed"></my-form>
$on handlers are never awaited. That is specified behaviour — do not sequence work on the return value; let async work write its own state slot when it settles. Since v1.24.0 a rejecting async handler is caught and reported through console.error naming the state and the handler, instead of surfacing as a bare unhandled rejection. Synchronous throws still propagate as programmer errors.
$onハンドラはawaitされない(仕様)。戻り値に処理を連ねず、非同期処理は完了時に自分で状態を書く。v1.24.0以降、rejectした非同期ハンドラは握りつぶされず、state名とハンドラ名つきでconsole.errorに報告される。同期的なthrowは従来どおり伝播する。
$streams #Fold async producers — async iterables, generators, ReadableStream — into reactive properties. When a dependency captured by args changes, the running source is aborted and restarted: switchMap semantics, declared.
非同期プロデューサをリアクティブなプロパティに畳み込む。argsの依存が変わると実行中のソースは中断されて再スタート(switchMap)。
export default {
prompt: "",
$streams: {
tokens: {
args: (state) => state.prompt, // dependency captured here
source: (prompt, signal) => llmStream(prompt, signal),
fold: (acc, chunk) => acc + chunk, // accumulate
initial: ""
},
ticker: {
source: (_args, signal) => priceStream(signal) // no args → start once, latest value
}
}
};
| Field | Required | Purpose |
|---|---|---|
source | ✔ | (args, signal) → AsyncIterable | ReadableStream. Must honor the AbortSignal |
args | — | Pure function capturing dependencies; omit to start once |
fold | — | (acc, chunk) → next. Omit for latest-value (replace) mode |
initial | with fold | Seed value |
<p data-wcs="textContent: $streamStatus.tokens"></p> <!-- "idle" | "active" | "done" | "error" -->
<p data-wcs="textContent: $streamError.tokens"></p> <!-- null or last error -->
To react to a stream that nothing renders, declare $watch on its value path — $updatedCallback is binding-driven and will not see it.
どこにも描画されないstreamに反応するには、値のパスに$watchを宣言する。$updatedCallbackはバインディング駆動なので届かない。
$watch #$updatedCallback is binding-driven: it reports the paths whose live DOM bindings were applied, so a value you never render is invisible to it. $watch, added in v1.27.0, is the headless counterpart — it fires on state changes whether or not anything on the page is bound to the path.
$updatedCallbackはバインディング駆動で、ライブなDOMバインディングが適用されたパスしか報告しない。描画していない値には届かない。v1.27.0で追加された$watchはそのヘッドレス版で、パスがどこにバインドされていてもいなくても、状態の変化で発火する。
export default {
isLoading: false,
items: [],
$listKeys: { items: "id" }, // makes the row watch below headless
$watch: {
// edge detection is yours: compare cur / prev
isLoading(cur, prev) {
if (cur === true && prev === false) { this.startedAt = Date.now(); }
},
// wildcard paths fire once per changed row; trailing args are loop indexes
"items.*.price"(cur, prev, index) {
this.lastPriceChange = `#${index}: ${prev} → ${cur}`;
}
}
};
The handler runs with this bound to a writable state proxy; its writes land in the next update batch, and chains of mutually-writing watches are cut at 32 links. prev is the value at the start of the batch and is scalar-only — it reuses the read the same-value guard already does, so watch costs no extra read, and it is undefined for reference types. Watching a computed getter makes it eager: evaluated at connect and at the end of every batch that touches its dependencies, so an unrendered computed can fire at all. Handler exceptions are isolated — reported to the console (and the devtools timeline), while the remaining watches still run. SSR never runs watches.
ハンドラのthisは書き込み可能なstateプロキシで、書き込みは次の更新バッチに乗る。相互に書き合うwatchの連鎖は32段で打ち切られる。prevはバッチ開始時点の値でスカラー限定 — same-value guardが既に行う読み取りを再利用するため追加コストがなく、参照型ではundefinedになる。算出getterをwatchするとeagerになり、接続時と依存が動いたバッチの終わりに毎回評価される — 描画されていない算出値が発火できるのはこのため。ハンドラ内の例外は隔離され、コンソール(とdevtoolsタイムライン)に報告された上で残りのwatchは実行される。SSRではwatchは動かない。
| Rule | Consequence |
|---|---|
| Own state only | A key may not carry @stateName — rejected at declaration |
No $-prefixed keys | $streamStatus.x cannot be watched directly — mirror it through a one-line non-$ getter and watch that (the eager rule makes it work unrendered) |
| Batches coalesce | a → b → c in one batch fires once with cur = c, prev = a |
Row watches want $listKeys | Without it a whole-array assignment fires every row with prev === undefined; headless row watching (no rendered for) fires zero times without it |
| Wildcard getters stay lazy | items.*.tax is not primed (that would sweep the list) — it fires only when also DOM-bound |
Mounted bind-component scopes excluded | A mounted scope does not execute declaration surfaces — the declaration is ignored with a one-time console warning (same for $streams). Declare on the root, or on a volume |
Tooling follows the declaration: @wcstack/lint and the VS Code extension check every $watch key statically (wcs/watch-declaration-invalid, wcs/watch-path-missing) — worth heeding, because a $watch typo does not fail visibly the way a binding typo does; it just never fires. The devtools timeline records watch-error and watch-chain-limit events, and as of v1.29.0 firing itself is measured: the runtime emits state:watch-fired, and the devtools coverage tab joins declared keys against it — each path shows fired ×N, never, or prerequisite-missing, kept distinct from “never”. Since v1.30.0 that prerequisite is exact: a wildcard’s list counts as satisfied when it is for-bound or $listKeys-declared — the same two conditions the rules table states — and when neither holds the note says assertively that the watch can never fire. Since v1.28.0 the runtime’s own declaration errors carry the same [wcs/watch-declaration-invalid] code plus a lint pointer, so console and CLI speak one vocabulary.
ツーリングも宣言を追う。@wcstack/lintとVS Code拡張は$watchのキーを静的に検証する(wcs/watch-declaration-invalid・wcs/watch-path-missing)。バインディングのタイプミスは「描画されない」形で見えるが、$watchのタイプミスは黙って一度も発火しないので、この検証は素通りしないこと。devtoolsのタイムラインにはwatch-errorとwatch-chain-limitが記録され、v1.29.0からは発火そのものが実測される。ランタイムがstate:watch-firedを発行し、devtoolsのcoverageタブが宣言キーと突き合わせる — 各パスはfired ×N・never・prerequisite-missing(「never」とは区別される)のいずれかを示す。v1.30.0からこの前提条件は正確になった。ワイルドカードのリストはforにバインドされているかまたは$listKeys宣言があれば満たされる — ルール表が述べるのと同じ2条件 — そしてどちらもないとき、ノートは「このwatchは発火し得ない」と言い切る。v1.28.0以降はランタイム自身の宣言エラーも同じ[wcs/watch-declaration-invalid]コードとlintへの誘導を運ぶので、コンソールとCLIが同じ語彙を話す。
bind-component="state" binds the element's own state field as the state source.
class MyComponent extends HTMLElement {
state = { message: "" };
constructor() {
super();
this.attachShadow({ mode: "open" });
this.shadowRoot.innerHTML = `
<wcs-state bind-component="state"></wcs-state>
<div>{{ message }}</div>
<input type="text" data-wcs="value: message" />
`;
}
}
customElements.define("my-component", MyComponent);
In v2 a Light DOM component is written exactly like the Shadow one — no name, no @ references. Scope is decided by position in the DOM, so the v1 rule “two instances carrying the same name cannot share a scope, use Shadow DOM for a component stamped on every row” is gone with the registry that caused it: the same Light DOM component can sit on every row of a list. The host must wire it, though — a plain, unwired Light DOM bind-component cannot exist in v2, because an independent tree cannot share the parent’s root; it fails loudly with the fix (attach a shadow root, or mount it from the host). getBindingsReady(root) now covers mounted scopes once the mount record resolves.
v2ではLight DOMコンポーネントはShadowと完全に同じ書き方になる — nameも@参照も要らない。スコープはDOM上の位置で決まるので、「同じnameの2インスタンスは1スコープに共存できない、リストの各行に置くならShadow DOMを使え」というv1の制約は、その原因だったレジストリごと消えた。同じLight DOMコンポーネントをリストの全行に置ける。ただしホストが必ず配線すること — 配線のない素のLight DOM bind-componentはv2では存在できない(独立した木は親のルートを共有できないため)。修正方法つきで大きく失敗する(shadow rootを付けるか、ホストからマウントする)。getBindingsReady(root)はマウント記録が解決すればマウント配下も覆う。
class MyLightComponent extends HTMLElement {
state = { message: "" };
connectedCallback() {
this.innerHTML = `
<wcs-state bind-component="state"></wcs-state>
<div data-wcs="text: message"></div>
<input type="text" data-wcs="value: message" />
`;
}
}
customElements.define("my-light-component", MyLightComponent);
// the host wires it: <my-light-component data-wcs="state: user">
// or <my-light-component data-wcs="state.message: user.name">
The parent page binds into a component's state from outside — still just paths.
<wcs-state>
<script type="module">
export default { user: { name: "Alice" } };
</script>
</wcs-state>
<my-component data-wcs="state.message: user.name"></my-component>
A standalone component can await readiness with async $stateReadyCallback(stateProp).
state: pathInstead of wiring property by property, mount a whole subtree as the component’s root — inside the component every path is relative to the mount point: <user-card data-wcs="state: user"> makes name inside be user.name (reads, writes, getters and for: included). In a loop, mount the row itself: <user-row data-wcs="state: .">. Partial mounts coexist by longest prefix (state: user; state.theme: theme); the component’s own keys stay private and shadowing a mount-point key warns once (wcs/mount-own-key-shadow). An array cannot be the mount root — mount the row or the object holding the array. Rule R1 is strict: a default declared for a mapped key keeps that key private and hides the host value, so drop the default to read the tree.
プロパティを1つずつ結線する代わりに、サブツリー全体をコンポーネントのルートとしてマウントする — 内側ではすべてのパスがマウント点からの相対になる。<user-card data-wcs="state: user">なら内側のnameはそのままuser.name(読み・書き・getter・for:を含む)。ループでは行そのものをマウントする: <user-row data-wcs="state: .">。部分マウントは最長プレフィクス勝ちで共存し(state: user; state.theme: theme)、コンポーネント自身のキーはprivateのまま、マウント先のキーを隠すと一度だけ警告される(wcs/mount-own-key-shadow)。配列をマウントルートにはできない — 行か、配列を持つオブジェクトをマウントする。規則R1は厳格で、mappedなキーに既定値を宣言するとそのキーは私有になりホストの値を隠す。木を読みたいなら既定値を外すこと。
Exported getters (v2.2.0). A mounted component’s getters are exported at its mount point: a read of a key the tree does not have is answered by the getter of the component mounted there — <span data-wcs="textContent: user.display"> renders the component’s get display(). A key the tree does have wins and warns once (wcs/mount-export-shadowed); private keys and methods are never visible. Row mounts export per row ($getAll("users.*.display")), dependencies flow through, and writing to an exported key runs the setter or throws. Accessors whose component-local path contains a wildcard are not exported. This is what makes self-recursive components expressible: a <tree-node> rendering <tree-node data-wcs="state: ."> per child can define get total() over this.$getAll("children.*.total"). The parent evaluates before the child registers, so write derived expressions defensively ((x ?? 0)).
エクスポートされたgetter(v2.2.0)。 マウントされたコンポーネントのgetterはマウント点にエクスポートされる: 木に無いキーの読みは、そこにマウントされたコンポーネントのgetterが答える — <span data-wcs="textContent: user.display">はコンポーネントのget display()を描画する。木にあるキーは木が勝ち、一度だけ警告する(wcs/mount-export-shadowed)。私有キーとメソッドは決して見えない。行マウントは行ごとにエクスポートし($getAll("users.*.display"))、依存は貫通し、エクスポートされたキーへの書き込みはsetterを走らせるか無ければthrowする。コンポーネント内のパスにワイルドカードを含むアクセサはエクスポートされない。これが自己再帰コンポーネントを可能にする: 子ごとに<tree-node data-wcs="state: .">を描く<tree-node>が、this.$getAll("children.*.total")の上にget total()を定義できる。親は子の登録より先に評価されるので、派生式は防御的に書く((x ?? 0))。
Define a reusable custom element entirely in HTML with data-wc-definition. Each instance gets its own state; $bindables declares the properties exposed through the wc-bindable protocol (with CustomEvent dispatch like my-counter:count-changed).
data-wc-definitionでHTMLだけの再利用可能コンポーネントを定義。各インスタンスは独立した状態を持ち、$bindablesで公開プロパティを宣言。
<my-counter data-wc-definition>
<template shadowrootmode="open">
<p>{{ count }}</p>
<button data-wcs="onclick: increment">+1</button>
<wcs-state>
<script type="module">
export default {
count: 0,
increment() { this.count++; },
$bindables: ["count"]
};
</script>
</wcs-state>
</template>
</my-counter>
<my-counter></my-counter>
<my-counter></my-counter>
Since v1.26.0, $commands does for methods what $bindables does for properties: each listed method becomes a commands entry in the generated manifest, so a parent can drive it with a command token exactly like an I/O node. Command entries are always async: true — a DCC method chains on the inner <wcs-state>'s initialization, so it returns a Promise whether or not it was written async.
v1.26.0以降、$commandsはメソッドに対して$bindablesと同じ役割を果たす。列挙したメソッドはマニフェストのcommandsエントリになり、親からI/Oノードと同じくコマンドトークンで駆動できる。エントリは常にasync: true。DCCのメソッドは内側の<wcs-state>の初期化に連なるため、asyncで書いたかどうかに関わらずPromiseを返す。
<!-- inside the definition's template -->
<wcs-state>
<script type="module">
export default {
count: 0,
bumpBy(step) { this.count += step; },
$bindables: ["count"],
$commands: ["bumpBy"]
};
</script>
</wcs-state>
<!-- the parent drives it; emit(3) calls bumpBy(3) -->
<my-counter data-wcs="count: parentCount; command.bumpBy: $command.bump"></my-counter>
Both declarations are validated when the component is defined. A duplicated entry — which used to fail silently, leaving the element quietly no longer two-way bindable — now raises, as do an entry starting with $, an entry that does not exist on the state, a method listed in $bindables, and a value property listed in $commands.
どちらの宣言もコンポーネント定義時に検証される。重複エントリ(従来は無言で失敗し、要素が双方向バインド不能になっていた)、$で始まるエントリ、stateに存在しないエントリ、$bindablesに挙げたメソッド、$commandsに挙げた値プロパティは、いずれもエラーになる。
DCC and bind-component both give a custom element its own state, and they are mutually exclusive — pick one per component. The rule of thumb: if the component has no JavaScript class, use DCC; if you are already writing a class, use bind-component. Combining them raises: a <wcs-state bind-component> inside a data-wc-definition host is a configuration error, because DCC state belongs to the template and is loaded per instance.
DCCとbind-componentはどちらもカスタム要素に独自の状態を与えるが、排他であり、コンポーネントごとにどちらか一方を選ぶ。目安は、JavaScriptのクラスを書かないならDCC、すでにクラスを書いているならbind-component。併用はエラーになる。data-wc-definitionホストの中の<wcs-state bind-component>は設定エラーで、DCCの状態はテンプレートに属しインスタンスごとに読み込まれるため。
| DCC | bind-component | |
|---|---|---|
| How the element is defined | HTML only (data-wc-definition + DSD) | A JS class extends HTMLElement you write |
| Where the state lives | An inline <script type="module"> in the template, per instance | A property on the instance (this.state) |
static wcBindable | Generated from $bindables / $commands | None — not a wc-bindable producer |
| Parent binds a value | count: parentCount (two-way) | state.msg: user.name (path mapping) |
| Parent invokes a method | command.bumpBy: $command.bump | Not available — expose it on the class |
Spread (...: obj) | Available | Not available (needs a manifest) |
bind-component components deliberately stay outside the wc-bindable protocol: they are wired by path, not by a declared property surface. That is why spread and command tokens, which both need a manifest, do not apply to them. A bind-component child placed inside a parent for: can, since v1.26.0, iterate a list handed to it from that row with a for: of its own — and v1.27.0 extends this to any depth and stacking: components inside components stack scopes, the base list index composes across every boundary, and an intermediate component that only passes the array through still delivers row-field writes to the rows at the bottom. Loop indexes stay scope-local ($1, handler indexes, $getAll all report positions within the component's own scope), so a component's author never has to know how deeply it is placed. One consequence of mounting: a mounted scope does not execute declaration surfaces, so $watch and $streams there are ignored with a one-time warning — declare them on the root state, or on a volume.
bind-componentのコンポーネントは意図的にwc-bindableプロトコルの外にいる。宣言されたプロパティ面ではなくパスで結線されるため、マニフェストを要するspreadとコマンドトークンは適用されない。v1.26.0以降、親のfor:の中に置いたbind-componentの子は、その行から渡されたリストを自分のfor:で回せる。v1.27.0はこれを任意の深さと積み重ねに拡張した。コンポーネントの中のコンポーネントはスコープを積み、基底のリストインデックスは境界ごとに合成され、配列を素通しするだけの中間コンポーネントがあっても行フィールドへの書き込みは最下層の行まで届く。ループインデックスはスコープ局所のまま($1・ハンドラのインデックス・$getAllはすべて自スコープ内の位置を報告する)なので、コンポーネントの作者は自分がどの深さに置かれるかを知らなくてよい。マッピングプロキシの帰結として、マップされた子は$watchと$streamsを宣言できない — ホスト側のstateに宣言する。
Custom elements expose a static manifest — observable properties, settable inputs, invocable commands — so any binding core can discover and wire them generically. All 30+ wcstack I/O nodes speak it; so can yours.
静的マニフェスト(properties / inputs / commands)を公開すれば、どのバインディングコアからも汎用的に結線できる。
class MyChip extends HTMLElement {
static wcBindable = {
protocol: "wc-bindable",
version: 1,
properties: [
{ name: "data", event: "thing-error" }
],
inputs: [
{ name: "data", attribute: "data" },
{ name: "labelText", attribute: "label-text" },
{ name: "internal" }
],
commands: [
{ name: "fetch", async: true },
{ name: "reset" }
]
};
}
When an input declares attribute, the framework writes both the property and the attribute:
| Value type | Attribute value |
|---|---|
| string / number / boolean / bigint | String(value) |
| null / undefined | attribute removed |
| object / array | JSON.stringify(value) |
#init= / #sync=)For wc-bindable elements, the modifier decides which side owns the wire:
| Authority | Effect |
|---|---|
init=state | State writes to element; element events update state (two-way) |
init=element | Element snapshot + events → state; state writes suppressed |
init=auto | Element-owned if uninitialized, else state-owned |
init=none | No initial sync (event bindings only) |
sync=call (default) reads the snapshot immediately; sync=connect defers until DOM connection.
Directional initial sync rule: declare every settable member in both properties and inputs for two-way. Output-only members declared only in properties become element-owned — state writes are suppressed.
双方向にしたいメンバーはpropertiesとinputsの両方に宣言する。propertiesのみのメンバーは出力専用(要素側が所有)。
Add enable-ssr to <wcs-state> and render the same HTML with @wcstack/server. The client hydrates automatically from the <wcs-ssr> JSON snapshot and skips $connectedCallback — zero flicker.
enable-ssrを付けて同じHTMLをサーバーでレンダリング。クライアントは<wcs-ssr>スナップショットから自動ハイドレーションし、$connectedCallbackはスキップされる。
import { renderToString } from "@wcstack/server";
const html = await renderToString(template, {
baseUrl: "http://localhost:3000"
});
v2.3.0: a snapshot no longer evaluates the state object’s own enumerable getters (a { get count() { … } } in the object literal). They were evaluated with the raw object as this, so a path getter serialized as null and a getter calling $getAll threw and took the whole page’s SSR down. Derived values are recomputed on the client from the same definition; a consumer that read a getter’s value out of the snapshot should read the data it derives from instead.
v2.3.0: スナップショットはstateオブジェクト自身の列挙可能なgetter(オブジェクトリテラル中の{ get count() { … } })を評価しなくなった。従来は生のオブジェクトをthisとして評価していたため、パスgetterはnullとして直列化され、$getAllを呼ぶgetterは例外を投げてページ全体のSSRを倒していた。派生値は同じ定義からクライアントで再計算される。スナップショットからgetterの値を読んでいた利用側は、その導出元のデータを読むように変えること。
All bindings work inside <svg> — use attr.NAME for SVG attributes.
<svg width="200" height="100">
<template data-wcs="for: points">
<circle data-wcs="attr.cx: .x; attr.cy: .y; attr.fill: .color" r="5" />
</template>
</svg>
defineState()Zero-cost type safety, including dot-path resolution. Utility types: WcsPaths<T>, WcsPathValue<T, P>. The wcstack-intellisense VS Code extension brings completion / diagnostics / hover to inline scripts in HTML.
ドットパス解決込みのゼロコスト型安全。VS Code拡張がHTML内インラインスクリプトに補完・診断を提供。
import { defineState } from "@wcstack/state";
export default defineState({
count: 0,
users: [] as { name: string; age: number }[],
increment() {
this.count++; // ✓ number
this["users.*.name"]; // ✓ string (dot-path resolution)
this.$getAll("users.*.age", []); // ✓ API method
},
get "users.*.ageCategory"() {
return this["users.*.age"] < 25 ? "Young" : "Adult";
}
});
bootstrapState()import { bootstrapState } from "@wcstack/state";
bootstrapState({
bindAttributeName: "data-wcs",
tagNames: { state: "wcs-state" },
locale: "en",
debug: false,
enableMustache: true,
enableDirectionalInitialSync: true, // default ON (near-zero cost)
enablePropagationContext: true, // default ON (near-zero cost)
enableContractAnalyzer: false // opt-in
});
Measured against 1,000 / 10,000-row benchmark tables in headless Chromium (drivers in e2e/bench/ reproduce the numbers on your hardware):
対話的操作はミリ秒以下。バルク操作はsignals比で約2.5〜3.5倍遅いが、その分の帳簿がDevToolsタイムラインとSSRハイドレーションを支えている。
Need raw throughput with the same tags? The JS-first core @wcstack/signals drives the same wc-bindable I/O nodes with a ≈3.1 KB fine-grained runtime.
スループット最優先なら、同じタグ群を駆動できるJSファーストの@wcstack/signalsへ。