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
state="id" → a <script type="application/json" id> elementsrc="*.json" or src="*.js" → external filejson='{ }' → inline JSON attribute<script type="module"> → export defaultsetInitialState() → programmatic APIGive a state a name and address it from anywhere with path@name.
nameを付けると、どこからでもpath@nameで参照できる。
<wcs-state name="cart">...</wcs-state>
<div data-wcs="textContent: total@cart"></div>
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);
}
};
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"];
}
| API | Purpose |
|---|---|
this.$getAll("path.*", []) | Collect all values for a wildcard path |
this.$resolve("path.*", [i], value?) | Read / write at an explicit index |
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) |
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.
リストは値ベースの差分。変化のない要素のDOMノードは再利用される。
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>
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)fix(n)round(n?)floorceillocale(loc?)percent(n?)uclccaptrimslice(n)substr(s,l)pad(n,c?)rep(n)revintfloatbooleannumberstringnulldate(loc?)time(loc?)datetime(loc?)ymd(sep?)truthyfalsydefaults(v)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>
$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 -->
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);
Light DOM shares the namespace with the parent, so a name attribute is required and bindings use @name.
Light DOMは親と名前空間を共有するため、name属性が必須。@nameで参照する。
class MyLightComponent extends HTMLElement {
state = { message: "" };
connectedCallback() {
this.innerHTML = `
<wcs-state bind-component="state" name="my-light"></wcs-state>
<div data-wcs="text: message@my-light"></div>
<input type="text" data-wcs="value: message@my-light" />
`;
}
}
customElements.define("my-light-component", MyLightComponent);
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).
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>
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"
});
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 ≈2.5 KB fine-grained runtime.
スループット最優先なら、同じタグ群を駆動できるJSファーストの@wcstack/signalsへ。