<wcs-state>

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による純粋なリアクティビティ。

v1.22.6 @wcstack/state 0 dependencies buildless MIT
index.htmlCDN via esm.run
<script type="module" src="https://esm.run/@wcstack/state/auto"></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 — this page

  • 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
@wcstack/signals

JS-first

  • 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
Read the signals docs →

The contract: paths #

A <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を一切参照しない。

The update rule

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.

更新は「パスへの代入」で検出される。オブジェクトの深い直接変更は検出されない。

state.jsUpdate rule
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

Defining state #

State sources (resolution order)

  1. state="id" → a <script type="application/json" id> element
  2. src="*.json" or src="*.js" → external file
  3. json='{ }' → inline JSON attribute
  4. Inner <script type="module">export default
  5. setInitialState() → programmatic API

Named state

Give a state a name and address it from anywhere with path@name.

nameを付けると、どこからでもpath@nameで参照できる。

index.htmlNamed state
<wcs-state name="cart">...</wcs-state>

<div data-wcs="textContent: total@cart"></div>

Lifecycle hooks

state.jsLifecycle
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);
  }
};

Path getters #

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.

算出プロパティはドットパス文字列のキーで、データの深さに関係なくフラットに定義する。ワイルドカード*がインデックスを抽象化する。

state.jsVirtual properties
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;
  }
};

Nested wildcards

state.jsMulti-level arrays
get "categories.*.items.*.label"() {
  return this["categories.*.name"] + " / " + this["categories.*.items.*.name"];
}

Proxy APIs (inside state)

APIPurpose
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)

Binding: data-wcs #

General form — multiple bindings separated by ;:

syntaxGeneral form
property[#modifier]: path[@state][|filter[|filter(args)...]]
index.htmlMultiple bindings
<div data-wcs="textContent: count; class.over: count|gt(10)"></div>

Bindable properties

PropertyBehavior
valueTwo-way for <input>, <select>, <textarea>
checkedTwo-way for checkbox / radio
textContent / textDOM text
htmlinnerHTML
class.NAMECSS class toggle
style.PROPCSS property
attr.NAMEHTML attribute (SVG-safe)
radioRadio group binding
checkboxCheckbox array binding
onclick, on*Event handlers

Modifiers

index.htmlModifiers
<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でイベント切替。

Mustache syntax

With enableMustache: true (the default), text interpolation works anywhere — internally converted to comment-based bindings.

index.htmlMustache
<p>Hello, {{ user.name }}!</p>
<p>Count: {{ count|locale }}</p>

Spread binding (...)

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は書き込みスキップ。

index.htmlSpread binding
<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>

Structural directives #

Loop: for

index.htmlfor
<template data-wcs="for: users">
  <div data-wcs="textContent: users.*.name"></div>  <!-- full path -->
  <div data-wcs="textContent: .name"></div>          <!-- dot shorthand -->
</template>
ShorthandExpands to
.nameusers.*.name
.users.* (current element)
.name|ucusers.*.name|uc (filters preserved)
.name@stateusers.*.name@state (state name preserved)
index.htmlNested loops
<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ノードは再利用される。

Conditional: if / elseif / else

index.htmlConditionals
<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>

Filters (40 built-in) #

Chain with |: price|mul(1.1)|round(2)|locale(ja-JP)

パイプで連結できる。

Comparison

eq(v)ne(v)notlt(n)le(n)gt(n)ge(n)

Arithmetic

inc(n)dec(n)mul(n)div(n)mod(n)

Number formatting

fix(n)round(n?)floorceillocale(loc?)percent(n?)

String

uclccaptrimslice(n)substr(s,l)pad(n,c?)rep(n)rev

Type conversion

intfloatbooleannumberstringnull

Date / time

date(loc?)time(loc?)datetime(loc?)ymd(sep?)

Boolean / default

truthyfalsydefaults(v)

Events & tokens #

Event handling

Handler signature: handler(event, ...listIndexes) — loop indexes arrive as extra arguments.

ハンドラにはイベントに続いてループのインデックスが渡される。

index.html + state.jsEvents
<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);
}

Command tokens (state → element)

Pub/sub channels for invoking element methods from state. Declare channels in $commandTokens, emit arguments with this.$command.<name>.emit().

状態から要素のメソッドを呼ぶpub/subチャンネル。

state.js + index.htmlCommand tokens
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 APIPurpose
emit(...args)Invoke subscribed methods; returns array of results
subscribe(fn)Add subscriber; returns unsubscribe function
unsubscribe(fn)Remove subscriber
name / sizeToken name / current subscriber count

Event tokens (element → state)

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。

state.js + index.htmlEvent tokens
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: $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)。

state.js$streams
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
    }
  }
};
FieldRequiredPurpose
source(args, signal) → AsyncIterable | ReadableStream. Must honor the AbortSignal
argsPure function capturing dependencies; omit to start once
fold(acc, chunk) → next. Omit for latest-value (replace) mode
initialwith foldSeed value
index.htmlStatus companions (read-only)
<p data-wcs="textContent: $streamStatus.tokens"></p>  <!-- "idle" | "active" | "done" | "error" -->
<p data-wcs="textContent: $streamError.tokens"></p>   <!-- null or last error -->

Building components #

Shadow DOM component

bind-component="state" binds the element's own state field as the state source.

my-component.jsShadow DOM
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 component

Light DOM shares the namespace with the parent, so a name attribute is required and bindings use @name.

Light DOMは親と名前空間を共有するため、name属性が必須。@nameで参照する。

my-light-component.jsLight DOM
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);

Host binding

The parent page binds into a component's state from outside — still just paths.

index.htmlHost binding
<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).

Declarative Custom Components (DCC)

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で公開プロパティを宣言。

index.htmlDCC
<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>

The wc-bindable protocol #

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)を公開すれば、どのバインディングコアからも汎用的に結線できる。

my-chip.jsManifest
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" }
    ]
  };
}

Attribute mirroring

When an input declares attribute, the framework writes both the property and the attribute:

Value typeAttribute value
string / number / boolean / bigintString(value)
null / undefinedattribute removed
object / arrayJSON.stringify(value)

Binding authority (#init= / #sync=)

For wc-bindable elements, the modifier decides which side owns the wire:

AuthorityEffect
init=stateState writes to element; element events update state (two-way)
init=elementElement snapshot + events → state; state writes suppressed
init=autoElement-owned if uninitialized, else state-owned
init=noneNo 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のみのメンバーは出力専用(要素側が所有)。

SSR & SVG #

Server-side rendering

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はスキップされる。

server.jsSSR
import { renderToString } from "@wcstack/server";

const html = await renderToString(template, {
  baseUrl: "http://localhost:3000"
});

SVG support

All bindings work inside <svg> — use attr.NAME for SVG attributes.

index.htmlSVG
<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>

TypeScript & configuration #

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内インラインスクリプトに補完・診断を提供。

state.tsdefineState
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()

main.jsConfiguration
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
});

Performance #

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