npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@wcstack/state

v1.13.0

Published

Reactive state management with declarative data binding for Web Components. Zero dependencies, buildless.

Readme

@wcstack/state

This is not another convenient frontend framework. It is a different lineage that rearranges the premises of frontend development.

Most libraries place the coupling point between UI, state, and components inside JavaScript. @wcstack/state does not. It assumes no virtual DOM, no compilation step, no hooks, no selectors. UI and state are connected by HTML and path strings alone.

That is what <wcs-state> and data-wcs explore. One CDN import, zero dependencies, pure HTML syntax. The CDN script only registers the custom element definition — nothing else happens at load time. When a <wcs-state> element connects to the DOM, it reads its state source, scans all data-wcs bindings within the same root node (document or ShadowRoot), and wires up reactivity. All initialization is driven by the element's lifecycle, not by your code.

What Does Not Exist Here

The following are not missing features. They do not exist by design.

  • APIs for pulling variables out of state into components
  • Per-element binding objects that mediate state access
  • hooks
  • selectors
  • glue code that imports reactive primitives into component code

None of these exist by design.

Why: this library does not put the UI-state coupling point inside JavaScript. State is not pulled into components. HTML refers to state through path strings. Elements do not own state, and state does not know elements. The only shared contract is the path.

Do Not Compare This to Existing Frameworks

This is not solving the same problem as React / Vue / Solid with a different syntax. The premises are different.

| What mainstream frameworks assume | What @wcstack/state assumes | |---|---| | Components are the coupling point between UI and state | Path strings are the coupling point between UI and state | | JavaScript is the center of rendering | HTML and the DOM are the center | | State is pulled into components | Paths are declared and the DOM connects to state | | hooks / selectors / signals express subscriptions | Attributes and paths express bindings | | The whole app runs inside a framework execution model | A thin reactive layer is added on top of web standards |

Before making a comparison chart, understand this difference in premises. These tools may live in the same ecosystem, but they cut the problem space very differently.

First Principle: Path as the Universal Contract

In every existing framework, the component is the coupling point between UI and state. Components import state hooks, selectors, or reactive primitives, and the binding happens inside JavaScript. No matter how cleanly you separate your state store, there is always glue code in the component that pulls state in.

@wcstack/state eliminates that coupling entirely. The only thing connecting UI and state is a path string — a dot-separated address like user.name or cart.items.*.subtotal. This is the sole contract between the two layers:

| Layer | What it knows | What it doesn't know | |-------|---------------|----------------------| | State (<wcs-state>) | Data structure and business logic | Which DOM nodes are bound | | UI (data-wcs) | Path strings and display intent | How state is stored or computed | | Components (@name) | The path they need from a named state | The other component's internals |

Three levels of path contracts keep everything loosely coupled:

  1. UI ↔ State — A data-wcs="textContent: user.name" attribute is the entire binding. No hooks, no selectors, no reactive primitives. The component's JavaScript doesn't contain a single line that references state.

  2. Component ↔ Component — Cross-component communication happens through named state references (@stateName). Components never import or depend on each other; they share a naming convention, nothing more.

  3. Loop context — Inside a for loop, * acts as an abstract index. Bindings like items.*.price resolve to the current element automatically. The template doesn't know its concrete position — the wildcard is the contract.

Why This Matters

This is complete separation of UI and state with no JavaScript intermediary. You can:

  • Redesign the entire UI without touching state logic
  • Refactor state structure and only update path strings
  • Read the HTML alone and understand every data dependency

The path contract works like a URL in a REST API — a simple string that both sides agree on, with no shared code between them. It's the natural result of building on HTML's declarative nature rather than inventing a template language on top of JavaScript.

Every feature below is a consequence of this principle. The principle comes first; the features follow from it.

4 Steps to Reactive HTML

<!-- 1. Load the CDN -->
<script type="module" src="https://esm.run/@wcstack/state/auto"></script>

<!-- 2. Write a <wcs-state> tag -->
<wcs-state>
  <!-- 3. Define your state object -->
  <script type="module">
    export default {
      message: "Hello, World!"
    };
  </script>
</wcs-state>

<!-- 4. Bind with data-wcs attributes -->
<div data-wcs="textContent: message"></div>

That's it. No build, no bootstrap code, no framework.

Features Derived from This Principle

  • Declarative data bindingdata-wcs attribute for property / text / event / structural binding
  • Reactive Proxy — ES Proxy-based automatic DOM updates with dependency tracking
  • Structural directivesfor, if / elseif / else via <template> elements
  • Built-in filters — 40 filters for formatting, comparison, arithmetic, date, and more
  • Two-way binding — automatic for <input>, <select>, <textarea>
  • Web Component binding — bidirectional state binding with Shadow DOM components
  • Command tokens — invoke methods on wc-bindable custom elements from state via a pub/sub channel (command.<method>: tokenName)
  • Event tokens — the dual of command tokens: receive a wc-bindable element's dispatched events in state via eventToken.<prop>: tokenName + the $on map
  • Path getters — dot-path key getters (get "users.*.fullName"()) for virtual properties at any depth in a data tree, all defined flat in one place with automatic dependency tracking and caching
  • Mustache syntax{{ path|filter }} in text nodes
  • Multiple state sources — JSON, JS module, inline script, API, attribute
  • SVG support — full binding support inside <svg> elements
  • Lifecycle hooks$connectedCallback / $disconnectedCallback / $updatedCallback, plus $stateReadyCallback for Web Components
  • TypeScript supportdefineState() for typed state definitions with dot-path autocompletion (details)
  • Server-Side Renderingenable-ssr attribute + @wcstack/server for full SSR with automatic hydration
  • Zero dependencies — no runtime dependencies

Installation

CDN (recommended)

<!-- Auto-initialization — this is all you need -->
<script type="module" src="https://esm.run/@wcstack/state/auto"></script>

CDN (manual initialization)

<script type="module">
  import { bootstrapState } from 'https://esm.run/@wcstack/state';
  bootstrapState();
</script>

Basic Usage

<wcs-state>
  <script type="module">
    export default {
      count: 0,
      user: { id: 1, name: "Alice" },
      users: [
        { id: 1, name: "Alice" },
        { id: 2, name: "Bob" },
        { id: 3, name: "Charlie" }
      ],
      countUp() { this.count += 1; },
      clearCount() { this.count = 0; },
      get "users.*.displayName"() {
        return this["users.*.name"] + " (ID: " + this["users.*.id"] + ")";
      }
    };
  </script>
</wcs-state>

<!-- Text binding -->
<div data-wcs="textContent: count"></div>
{{ count }}

<!-- Two-way input binding -->
<input type="text" data-wcs="value: user.name">

<!-- Event binding -->
<button data-wcs="onclick: countUp">Increment</button>

<!-- Conditional class -->
<div data-wcs="textContent: count; class.over: count|gt(10)"></div>

<!-- Loop -->
<template data-wcs="for: users">
  <div>
    <span data-wcs="textContent: .id"></span>:
    <span data-wcs="textContent: .displayName"></span>
  </div>
</template>

<!-- Conditional rendering -->
<template data-wcs="if: count|gt(0)">
  <p>The count is positive.</p>
</template>
<template data-wcs="elseif: count|lt(0)">
  <p>The count is negative.</p>
</template>
<template data-wcs="else:">
  <p>The count is zero.</p>
</template>

State Initialization

<wcs-state> supports multiple ways to load initial state:

<!-- 1. Reference a <script type="application/json"> by id -->
<script type="application/json" id="state">
  { "count": 0 }
</script>
<wcs-state state="state"></wcs-state>

<!-- 2. Inline JSON attribute -->
<wcs-state json='{ "count": 0 }'></wcs-state>

<!-- 3. External JSON file -->
<wcs-state src="./data.json"></wcs-state>

<!-- 4. External JS module (export default { ... }) -->
<wcs-state src="./state.js"></wcs-state>

<!-- 5. Inline script module -->
<wcs-state>
  <script type="module">
    export default { count: 0 };
  </script>
</wcs-state>

<!-- 6. Programmatic API -->
<script>
  const el = document.createElement('wcs-state');
  el.setInitialState({ count: 0 });
  document.body.appendChild(el);
</script>

Resolution order: statesrc (.json / .js) → json → inner <script> → wait for setInitialState().

Named State

Multiple state elements can coexist with the name attribute. Bindings reference them with @name:

<wcs-state name="cart">...</wcs-state>
<wcs-state name="user">...</wcs-state>

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

Default name is "default" (no @ needed).

Updating State

In @wcstack/state, every piece of state has a path — like count, user.name, or items. To update state reactively, assign to the path:

this.count = 10;               // path "count"
this["user.name"] = "Bob";     // path "user.name"

That's the one rule: assign to the path, and the DOM updates automatically.

Why this.user.name = "Bob" Doesn't Work

This is not just a limitation. It is where the contract boundary becomes visible.

this.user.name first reads the user object via this.user (a path read), then sets .name on that plain object — this does not go through the contract of path assignment, so the change is not detected:

// ✅ Path assignment — change detected
this["user.name"] = "Bob";

// ❌ Not a path assignment — change NOT detected
this.user.name = "Bob";

It may seem more convenient to make this.user.name = "Bob" reactive too. But doing that would break the principle that UI and state are connected only through paths. Dependency tracking and update boundaries would become implicit and ambiguous. The visible contract boundary is the point.

Arrays

The same rule applies: assign a new array to the path. Mutating methods (push, splice, sort, ...) modify the array in place without path assignment, so use non-destructive alternatives:

// ✅ New array assigned to path — change detected
this.items = this.items.concat({ id: 4, text: "New" });
this.items = this.items.toSpliced(index, 1);
this.items = this.items.filter(item => !item.done);
this.items = this.items.toSorted((a, b) => a.id - b.id);
this.items = this.items.toReversed();
this.items = this.items.with(index, newValue);

// ❌ In-place mutation — no path assignment, change NOT detected
this.items.push({ id: 4, text: "New" });
this.items.splice(index, 1);
this.items.sort((a, b) => a.id - b.id);

Binding Syntax

data-wcs Attribute

property[#modifier]: path[@state][|filter[|filter(args)...]]

Multiple bindings separated by ;:

<div data-wcs="textContent: count; class.over: count|gt(10)"></div>

| Part | Description | Example | |---|---|---| | property | DOM property to bind | value, textContent, checked | | #modifier | Binding modifier | #ro, #prevent, #stop, #onchange | | path | State property path | count, user.name, users.*.name | | @state | Named state reference | @cart, @user | | \|filter | Transform filter chain | \|gt(0), \|round\|locale |

Property Types

| Property | Description | |---|---| | value | Element value (two-way for inputs) | | checked | Checkbox / radio checked state (two-way) | | textContent | Text content | | text | Alias for textContent | | html | innerHTML | | class.NAME | Toggle a CSS class | | style.PROP | Set a CSS style property | | attr.NAME | Set an attribute (supports SVG namespace) | | radio | Radio button group binding (two-way) | | checkbox | Checkbox group binding to array (two-way) | | onclick, on* | Event handler binding |

Modifiers

| Modifier | Description | |---|---| | #ro | Read-only — disables two-way binding | | #prevent | Calls event.preventDefault() on event handlers | | #stop | Calls event.stopPropagation() on event handlers | | #onchange | Uses change event instead of input for two-way binding |

Two-Way Binding

Automatically enabled for:

| Element | Property | Event | |---|---|---| | <input type="checkbox/radio"> | checked | input | | <input> (other types) | value, valueAsNumber, valueAsDate | input | | <select> | value | change | | <textarea> | value | input |

<input type="button"> is excluded. Use #ro to disable, #onchange to change the event.

Radio Binding

Bind a radio button group to a single state value with radio:

<input type="radio" value="red" data-wcs="radio: selectedColor">
<input type="radio" value="blue" data-wcs="radio: selectedColor">

The radio button whose value matches the state value is automatically checked. When the user selects a different radio button, the state is updated. Use #ro for read-only.

Inside a for loop:

<template data-wcs="for: branches">
  <label>
    <input type="radio" data-wcs="value: .; radio: currentBranch">
    {{ . }}
  </label>
</template>

Checkbox Binding

Bind a checkbox group to a state array with checkbox:

<input type="checkbox" value="apple" data-wcs="checkbox: selectedFruits">
<input type="checkbox" value="banana" data-wcs="checkbox: selectedFruits">
<input type="checkbox" value="orange" data-wcs="checkbox: selectedFruits">

A checkbox is checked when its value is included in the state array. Toggling a checkbox adds or removes the value from the array. Use |int to convert string values to numbers, and #ro for read-only.

Mustache Syntax

When enableMustache is true (default), {{ expression }} in text nodes is supported:

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

Internally converted to comment-based bindings (<!--@@:expression-->).

Spread Binding (...)

For custom elements that declare the wc-bindable protocol, ...: target wires all of the element's properties + inputs to a single state object in one line:

<wcs-fetch data-wcs="...: usersFetch"></wcs-fetch>
export default {
  usersFetch: {
    url: "/api/users",
    method: "GET",
    value: null,
    loading: false,
    error: null,
    status: null,
  }
}

Runtime reads customClass.wcBindable.properties + inputs and expands each name into an individual binding (usersFetch.value, usersFetch.url, ...).

Scope: spread covers the data surfaces (properties + inputs). commands and event tokens are intentionally not included — wire them explicitly so the pub/sub points remain visible in HTML.

Inside a for loop: use ...: items.* (recommended) or the dot shortcut ...: .:

<template data-wcs="for: storesFetches">
  <wcs-fetch data-wcs="...: storesFetches.*"></wcs-fetch>
</template>

Last-wins override — explicit binding after ... overrides the spread:

<wcs-fetch data-wcs="...: usersFetch; status: alternateStatus"></wcs-fetch>

undefined is "no opinion" — when an expanded state path resolves to undefined (e.g. the slot object doesn't initialize that input), the property write is skipped and the element keeps its own default. You only need to initialize the paths you actually use; usersFetch: { value: null, loading: false } is enough even though <wcs-fetch> also declares method / manual / body. To explicitly clear a value, assign nullnull is always written. (This skip applies to every property binding, not just spread; with config.debug each skipped write is logged via console.debug.)

Constraints:

  • Filters on the spread target (...: target|filter) are rejected.
  • The right-hand path may contain * anywhere (e.g. ...: stores.*.fetch).
  • @stateName propagates to every expanded entry (...: fetchX@store).
  • If the custom element class is not yet registered, expansion is deferred until customElements.whenDefined(tag) resolves — autoloader-style late registration is supported.
  • Elements without a wcBindable declaration are rejected (write bindings explicitly). Spread requires the contract to know what to expand.

Composite shells (wc-bindable Composition Profile) are supported transparently: a composite shell exposes its synthesized declaration through the standard target.constructor.wcBindable surface, and composed names like "s3.progress" are kept as flat element member keys. Mirror the composed structure in state ({ s3: { progress: 0 } }) and ...: pipeline expands into nested state paths automatically.

Structural Directives

Structural directives use <template> elements:

Loop (for)

<template data-wcs="for: users">
  <div>
    <!-- Full path -->
    <span data-wcs="textContent: users.*.name"></span>
    <!-- Shorthand (relative to loop context) -->
    <span data-wcs="textContent: .name"></span>
  </div>
</template>

The for: directive uses a value-based diff algorithm — each array element's value itself serves as the identity key. There is no need for an explicit key attribute (like React's key or Vue's :key). When the array is reassigned, the differ matches old and new elements by value, reusing existing DOM nodes for unchanged items and efficiently adding, removing, or reordering the rest.

Dot Shorthand

Inside a for loop, paths starting with . are expanded relative to the loop's array path:

| Shorthand | Expanded to | Description | |---|---|---| | .name | users.*.name | Property of the current element | | . | users.* | The current element itself | | .name\|uc | users.*.name\|uc | Filters are preserved | | .name@state | users.*.name@state | State name is preserved |

For primitive arrays, . refers to the element value directly:

<template data-wcs="for: branches">
  <label>
    <input type="radio" data-wcs="value: .; radio: currentBranch">
    {{ . }}
  </label>
</template>

Nested loops are supported with multi-level wildcards. The . shorthand in nested for directives also expands relative to the parent loop path:

<template data-wcs="for: regions">
  <!-- .states → regions.*.states -->
  <template data-wcs="for: .states">
    <!-- .name → regions.*.states.*.name -->
    <span data-wcs="textContent: .name"></span>
  </template>
</template>

Conditional (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>

Conditions can be chained. elseif automatically inverts the previous condition.

Path Getters (Computed Properties)

Path getters are the core feature of @wcstack/state. Define computed properties using JavaScript getters with dot-path string keys containing wildcards (*). They act as virtual properties that can be attached at any depth in a data tree — all defined flat in one place. No matter how deeply data is nested, path getters keep definitions at the same level with automatic dependency tracking per loop element.

Basic Path Getter

<wcs-state>
  <script type="module">
    export default {
      users: [
        { id: 1, firstName: "Alice", lastName: "Smith" },
        { id: 2, firstName: "Bob", lastName: "Jones" }
      ],
      // Path getter — runs per-element inside a loop
      get "users.*.fullName"() {
        return this["users.*.firstName"] + " " + this["users.*.lastName"];
      },
      get "users.*.displayName"() {
        return this["users.*.fullName"] + " (ID: " + this["users.*.id"] + ")";
      }
    };
  </script>
</wcs-state>

<template data-wcs="for: users">
  <div data-wcs="textContent: .displayName"></div>
</template>
<!-- Output:
  Alice Smith (ID: 1)
  Bob Jones (ID: 2)
-->

Inside a path getter, this["users.*.firstName"] automatically resolves to the current loop element — no manual indexing needed.

Top-Level Computed Properties

Getters without wildcards work as standard computed properties:

export default {
  price: 100,
  tax: 0.1,
  get total() {
    return this.price * (1 + this.tax);
  }
};

Getter Chaining

Path getters can reference other path getters, forming a dependency chain. The cache is automatically invalidated when any upstream value changes:

<wcs-state>
  <script type="module">
    export default {
      taxRate: 0.1,
      cart: {
        items: [
          { productId: "P001", quantity: 2, unitPrice: 500 },
          { productId: "P002", quantity: 1, unitPrice: 1200 }
        ]
      },
      // Per-item subtotal
      get "cart.items.*.subtotal"() {
        return this["cart.items.*.unitPrice"] * this["cart.items.*.quantity"];
      },
      // Aggregate: sum of all subtotals
      get "cart.totalPrice"() {
        return this.$getAll("cart.items.*.subtotal", []).reduce((sum, v) => sum + v, 0);
      },
      // Chained: tax derived from totalPrice
      get "cart.tax"() {
        return this["cart.totalPrice"] * this.taxRate;
      },
      // Chained: grand total
      get "cart.grandTotal"() {
        return this["cart.totalPrice"] + this["cart.tax"];
      }
    };
  </script>
</wcs-state>

<template data-wcs="for: cart.items">
  <div>
    <span data-wcs="textContent: .productId"></span>:
    <span data-wcs="textContent: .subtotal|locale"></span>
  </div>
</template>
<p>Total: <span data-wcs="textContent: cart.totalPrice|locale"></span></p>
<p>Tax: <span data-wcs="textContent: cart.tax|locale"></span></p>
<p>Grand Total: <span data-wcs="textContent: cart.grandTotal|locale"></span></p>

Dependency chain: cart.grandTotalcart.taxcart.totalPricecart.items.*.subtotalcart.items.*.unitPrice / cart.items.*.quantity. Changing any item's unitPrice or quantity automatically recomputes the entire chain.

Nested Wildcard Getters

Multiple wildcards are supported for nested array structures:

<wcs-state>
  <script type="module">
    export default {
      categories: [
        {
          name: "Fruits",
          items: [
            { name: "Apple", price: 150 },
            { name: "Banana", price: 100 }
          ]
        },
        {
          name: "Vegetables",
          items: [
            { name: "Carrot", price: 80 }
          ]
        }
      ],
      get "categories.*.items.*.label"() {
        return this["categories.*.name"] + " / " + this["categories.*.items.*.name"];
      }
    };
  </script>
</wcs-state>

<template data-wcs="for: categories">
  <h3 data-wcs="textContent: .name"></h3>
  <template data-wcs="for: .items">
    <div data-wcs="textContent: .label"></div>
  </template>
</template>
<!-- Output:
  Fruits
    Fruits / Apple
    Fruits / Banana
  Vegetables
    Vegetables / Carrot
-->

Flat Virtual Properties Across Any Depth

A key advantage of path getters is that no matter how deeply data is nested, all virtual properties are defined flat in one place. This eliminates the need to split components just to hold computed properties at each nesting level.

export default {
  regions: [
    { name: "Kanto", prefectures: [
      { name: "Tokyo", cities: [
        { name: "Shibuya", population: 230000, area: 15.11 },
        { name: "Shinjuku", population: 346000, area: 18.22 }
      ]},
      { name: "Kanagawa", cities: [
        { name: "Yokohama", population: 3750000, area: 437.56 }
      ]}
    ]}
  ],

  // --- All flat, regardless of nesting depth ---

  // City level — virtual properties
  get "regions.*.prefectures.*.cities.*.density"() {
    return this["regions.*.prefectures.*.cities.*.population"]
         / this["regions.*.prefectures.*.cities.*.area"];
  },
  get "regions.*.prefectures.*.cities.*.label"() {
    return this["regions.*.prefectures.*.name"] + " "
         + this["regions.*.prefectures.*.cities.*.name"];
  },

  // Prefecture level — aggregate from cities
  get "regions.*.prefectures.*.totalPopulation"() {
    return this.$getAll("regions.*.prefectures.*.cities.*.population", [])
      .reduce((a, b) => a + b, 0);
  },

  // Region level — aggregate from prefectures
  get "regions.*.totalPopulation"() {
    return this.$getAll("regions.*.prefectures.*.totalPopulation", [])
      .reduce((a, b) => a + b, 0);
  },

  // Top level — aggregate from regions
  get totalPopulation() {
    return this.$getAll("regions.*.totalPopulation", [])
      .reduce((a, b) => a + b, 0);
  }
};

Three levels of nesting, five virtual properties — all defined side by side in a single flat object. Each level can reference values from any depth, and aggregation flows naturally from bottom to top via $getAll. In component-based frameworks, the typical approach is to create a separate component for each nesting level and pass computed values through the tree. Path getters offer a different trade-off by keeping all definitions in one place.

Accessing Sub-Properties of Getter Results

When a path getter returns an object, you can access its sub-properties via dot-path:

export default {
  products: [
    { id: "P001", name: "Widget", price: 500, stock: 10 },
    { id: "P002", name: "Gadget", price: 1200, stock: 3 }
  ],
  cart: {
    items: [
      { productId: "P001", quantity: 2 },
      { productId: "P002", quantity: 1 }
    ]
  },
  get productByProductId() {
    return new Map(this.products.map(p => [p.id, p]));
  },
  // Returns the full product object
  get "cart.items.*.product"() {
    return this.productByProductId.get(this["cart.items.*.productId"]);
  },
  // Access sub-property of the returned object
  get "cart.items.*.total"() {
    return this["cart.items.*.product.price"] * this["cart.items.*.quantity"];
  }
};

this["cart.items.*.product.price"] transparently chains through the object returned by the cart.items.*.product getter.

Path Setters

Custom setter logic can be defined with set "path"():

export default {
  users: [
    { firstName: "Alice", lastName: "Smith" },
    { firstName: "Bob", lastName: "Jones" }
  ],
  get "users.*.fullName"() {
    return this["users.*.firstName"] + " " + this["users.*.lastName"];
  },
  set "users.*.fullName"(value) {
    const [first, ...rest] = value.split(" ");
    this["users.*.firstName"] = first;
    this["users.*.lastName"] = rest.join(" ");
  }
};
<template data-wcs="for: users">
  <input type="text" data-wcs="value: .fullName">
</template>

Two-way binding works with path setters — editing the input calls the setter, which splits and writes back to firstName / lastName.

Supported Path Getter Patterns

| Pattern | Description | Example | |---|---|---| | get prop() | Top-level computed | get total() | | get "a.b"() | Nested computed (no wildcard) | get "cart.totalPrice"() | | get "a.*.b"() | Single wildcard | get "users.*.fullName"() | | get "a.*.b.*.c"() | Multiple wildcards | get "categories.*.items.*.label"() | | set "a.*.b"(v) | Wildcard setter | set "users.*.fullName"(v) |

How It Works

  1. Context resolution — When a for: loop renders, each iteration pushes a ListIndex onto the address stack. Inside a path getter, this["users.*.name"] resolves the * using this stack, so it always points to the current element.

  2. Automatic dependency tracking — When a getter accesses this["users.*.name"], the system registers a dynamic dependency from users.*.name to the getter's path. When users.*.name changes, the getter's cache is dirtied.

  3. Caching — Getter results are cached per concrete address (path + loop index). users.*.fullName at index 0 has a separate cache entry from index 1. The cache is invalidated only when dependencies change.

  4. Direct index access — You can also access specific elements by numeric index: this["users.0.name"] resolves as users[0].name without needing loop context.

Loop Index Variables ($1, $2, ...)

Inside getters and event handlers, this.$1, this.$2, etc. provide the current loop iteration index (0-based value, 1-based naming):

export default {
  users: ["Alice", "Bob", "Charlie"],
  get "users.*.rowLabel"() {
    return "#" + (this.$1 + 1) + ": " + this["users.*"];
  }
};
<template data-wcs="for: users">
  <div data-wcs="textContent: .rowLabel"></div>
</template>
<!-- Output:
  #1: Alice
  #2: Bob
  #3: Charlie
-->

For nested loops, $1 is the outer index and $2 is the inner index.

You can also display the loop index directly in templates:

<template data-wcs="for: items">
  <td>{{ $1|inc(1) }}</td>  <!-- 1-based row number -->
</template>

Proxy APIs

Inside state objects (getters / methods), the following APIs are available via this:

| API | Description | |---|---| | this.$getAll(path, indexes?) | Get all values matching a wildcard path | | this.$resolve(path, indexes, value?) | Resolve a wildcard path with specific indexes | | this.$postUpdate(path) | Manually trigger update notification for a path | | this.$trackDependency(path) | Manually register a dependency for cache invalidation | | this.$command.<name> | Access a CommandToken declared in $commandTokens (see Command Token) | | this.$stateElement | Access to the IStateElement instance | | this.$1, this.$2, ... | Current loop index (1-based naming, 0-based value) |

$getAll — Aggregate Across Array Elements

$getAll collects all values that match a wildcard path, returning them as an array. Essential for aggregation patterns:

export default {
  scores: [85, 92, 78, 95, 88],
  get average() {
    const all = this.$getAll("scores.*", []);
    return all.reduce((sum, v) => sum + v, 0) / all.length;
  },
  get max() {
    return Math.max(...this.$getAll("scores.*", []));
  }
};

$resolve — Access by Explicit Index

$resolve reads or writes a value at a specific wildcard index:

export default {
  items: ["A", "B", "C"],
  swapFirstTwo() {
    const a = this.$resolve("items.*", [0]);
    const b = this.$resolve("items.*", [1]);
    this.$resolve("items.*", [0], b);
    this.$resolve("items.*", [1], a);
  }
};

Event Handling

Bind event handlers with on* properties:

<button data-wcs="onclick: handleClick">Click me</button>
<form data-wcs="onsubmit#prevent: handleSubmit">...</form>

Handler methods receive the event and loop indexes:

export default {
  items: ["A", "B", "C"],
  handleClick(event) {
    console.log("clicked");
  },
  removeItem(event, index) {
    // index is the loop context ($1)
    this.items = this.items.toSpliced(index, 1);
  }
};
<template data-wcs="for: items">
  <button data-wcs="onclick: removeItem">Delete</button>
</template>

Filters

40 built-in filters are available for both input (DOM → state) and output (state → DOM) directions.

Comparison

| Filter | Description | Example | |---|---|---| | eq(value) | Equal | count\|eq(0)true/false | | ne(value) | Not equal | count\|ne(0) | | not | Boolean NOT | isActive\|not | | lt(n) | Less than | count\|lt(10) | | le(n) | Less than or equal | count\|le(10) | | gt(n) | Greater than | count\|gt(0) | | ge(n) | Greater than or equal | count\|ge(0) |

Arithmetic

| Filter | Description | Example | |---|---|---| | inc(n) | Add | count\|inc(1) | | dec(n) | Subtract | count\|dec(1) | | mul(n) | Multiply | price\|mul(1.1) | | div(n) | Divide | total\|div(100) | | mod(n) | Modulo | index\|mod(2) |

Number Formatting

| Filter | Description | Example | |---|---|---| | fix(n) | Fixed decimal places | price\|fix(2)"100.00" | | round(n?) | Round | value\|round(2) | | floor(n?) | Floor | value\|floor | | ceil(n?) | Ceiling | value\|ceil | | locale(loc?) | Locale number format | count\|locale / count\|locale(ja-JP) | | percent(n?) | Percentage format | ratio\|percent(1) |

String

| Filter | Description | Example | |---|---|---| | uc | Upper case | name\|uc | | lc | Lower case | name\|lc | | cap | Capitalize | name\|cap | | trim | Trim whitespace | text\|trim | | slice(n) | Slice string | text\|slice(5) | | substr(start, length) | Substring | text\|substr(0,10) | | pad(n, char?) | Pad start | id\|pad(5,0)"00001" | | rep(n) | Repeat | text\|rep(3) | | rev | Reverse | text\|rev |

Type Conversion

| Filter | Description | Example | |---|---|---| | int | Parse integer | input\|int | | float | Parse float | input\|float | | boolean | To boolean | value\|boolean | | number | To number | value\|number | | string | To string | value\|string | | null | To null | value\|null |

Date / Time

| Filter | Description | Example | |---|---|---| | date(loc?) | Date format | timestamp\|date / timestamp\|date(ja-JP) | | time(loc?) | Time format | timestamp\|time | | datetime(loc?) | Date + Time | timestamp\|datetime(en-US) | | ymd(sep?) | YYYY-MM-DD | timestamp\|ymd / timestamp\|ymd(/) |

Boolean / Default

| Filter | Description | Example | |---|---|---| | truthy | Truthy check | value\|truthy | | falsy | Falsy check | value\|falsy | | defaults(v) | Fallback value | name\|defaults(Anonymous) |

Filter Chaining

Filters can be chained with |:

<div data-wcs="textContent: price|mul(1.1)|round(2)|locale(ja-JP)"></div>

Web Component Binding

@wcstack/state supports bidirectional state binding with custom elements using Shadow DOM or Light DOM.

Many frameworks use patterns like prop drilling, context providers, or external stores (Redux, Pinia) to share state across components. @wcstack/state takes a different approach: parent and child components are connected through path contracts — the parent binds an outer state path to an inner component property via data-wcs, and the child simply reads and writes its own state as usual:

  1. The child references and updates the parent's state through its own state proxy — no props, no events, no awareness of the parent.
  2. When the parent's state changes, the Proxy set trap automatically notifies any child bindings that reference the affected path.
  3. Because the only coupling is the path name, both sides remain loosely coupled and independently testable.
  4. The cost is path resolution (cached at O(1) after first access) plus change propagation through the dependency graph.

This provides a lightweight approach to cross-component state management based on path resolution rather than component-level abstractions.

Component Definition (Shadow 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);

Component Definition (Light DOM)

Light DOM components do not use Shadow DOM. The state namespace is shared with the parent scope (just like CSS), so a name attribute is required.

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);
  • name attribute is required for Light DOM components (namespace is shared with the parent scope)
  • Bindings must explicitly reference the state name with @my-light
  • <wcs-state> must be a direct child of the component element

Host Usage

<wcs-state>
  <script type="module">
    export default {
      user: { name: "Alice" }
    };
  </script>
</wcs-state>

<!-- Bind component's state.message to outer user.name -->
<my-component data-wcs="state.message: user.name"></my-component>
  • bind-component="state" maps the component's state property to <wcs-state>
  • data-wcs="state.message: user.name" on the host element binds outer state paths to inner component state properties
  • Changes propagate bidirectionally between the component and the outer state

Standalone Web Component Injection (__e2e__/single-component)

Even when a component is independent from outer host state, you can inject reactive state with bind-component.

class MyComponent extends HTMLElement {
  state = Object.freeze({
    message: "Hello, World!"
  });

  constructor() {
    super();
    this.attachShadow({ mode: "open" });
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <wcs-state bind-component="state"></wcs-state>
      <div>{{ message }}</div>
    `;
  }

  async $stateReadyCallback(stateProp) {
    console.log("state ready:", stateProp); // "state"
  }
}
customElements.define("my-component", MyComponent);
  • Initial component state can be defined with Object.freeze(...) (it is replaced with a writable reactive state after injection)
  • bind-component="state" exposes this.state as a state proxy powered by @wcstack/state
  • Assignments like this.state.message = "..." immediately update {{ message }} inside Shadow DOM
  • async $stateReadyCallback(stateProp) is called right after component state becomes ready for use (stateProp is the property name from bind-component)

Constraints

  • <wcs-state> with bind-component must be a direct child of the component element (top-level)
  • The parent element must be a custom element (tag name containing a hyphen)
  • Light DOM components require a name attribute to avoid namespace conflicts with the parent scope
  • Light DOM bindings must reference the state name explicitly (e.g., @my-light)

Loop with Components

<template data-wcs="for: users">
  <my-component data-wcs="state.message: .name"></my-component>
</template>

Command Token (Method Binding)

Property binding (state.message: user.name) covers data flowing into a component, but it does not cover invoking a method on a component from state<wcs-fetch>.fetch(), <wcs-dialog>.open(), and so on. Command tokens fill that gap with a typed pub/sub channel:

  • The element subscribes via command.<methodName>: $command.<tokenName>
  • State emits via this.$command.<tokenName>.emit(...args)
  • Arguments passed to emit are forwarded to the element's method
  • One token can fan out to multiple elements; the subscriber order is preserved

This keeps the path contract intact: state never holds a reference to the element, and the element never imports anything from state. The token is the only shared object.

Basic Usage

<wcs-state>
  <script type="module">
    export default {
      $commandTokens: ["fetchUsers", "refreshOrders"],

      onClickFetch() {
        this.$command.fetchUsers.emit("/api/users", { method: "GET" });
      },
      onClickRefresh() {
        this.$command.refreshOrders.emit();
      }
    };
  </script>
</wcs-state>

<!-- Subscribers — must be wc-bindable custom elements -->
<wcs-fetch data-wcs="command.fetch: $command.fetchUsers"></wcs-fetch>
<wcs-fetch data-wcs="command.fetch: $command.refreshOrders"></wcs-fetch>

<button data-wcs="onclick: onClickFetch">Fetch users</button>
<button data-wcs="onclick: onClickRefresh">Refresh orders</button>

When onClickFetch runs, every element subscribed to the fetchUsers token has its fetch(...) method called with the forwarded arguments.

$commandTokens Declaration

The $commandTokens array declares the channels exposed under the $command namespace on state. Tokens are accessed as this.$command.<name> and are memoized — the same name always returns the same token instance.

export default {
  $commandTokens: ["fetchUsers", "refreshOrders"],

  click() {
    this.$command.fetchUsers.emit("/api/users");
  }
};
  • Entries must be non-empty strings
  • Duplicate entries throw an error at initialization
  • The reserved name $command itself cannot appear in the array
  • Tokens are gathered under $command so they do not pollute the top-level state namespace; reactive properties with the same name as a token can coexist
  • Accessing an undeclared name on $command (e.g. this.$command.typo) returns undefined. The typo then surfaces as a TypeError on the subsequent .emit() call, or — when used as a binding right-hand side — as a "requires a CommandToken value" error at binding time

command.<methodName>: Binding

<wcs-fetch data-wcs="command.fetch: $command.fetchUsers"></wcs-fetch>

| Part | Description | |---|---| | command. | Fixed prefix | | <methodName> | The element's method to invoke. The name must appear as { name: "<methodName>" } in static wcBindable.commands | | $command.<tokenName> | Explicit namespace path that resolves to a CommandToken. <tokenName> must be a name declared in $commandTokens |

The right-hand side must be written as $command.<tokenName> — the bare-name shorthand (fetchUsers) is not supported. Going through the $command. namespace makes the binding's intent explicit in the HTML and keeps the top-level state namespace free of token names.

wcBindable.commands follows the wc-bindable v1 spec shape — an array of { name: string; async?: boolean }:

class MyFetcher extends HTMLElement {
  static wcBindable = {
    protocol: "wc-bindable", version: 1,
    properties: [],
    commands: [
      { name: "fetch", async: true },
      { name: "reset" },
    ],
  };
  fetch(url) { /* ... */ }
  reset()    { /* ... */ }
}

Breaking change since v1.9.1: the commands field is now an array of { name, async? } objects. The earlier commands: ["fetch"] plain-string form is no longer accepted — bindings against such declarations throw Command "<name>" is not declared in wcBindable.commands. There is no legacy fallback; update the declaration to the object form.

Validation rules (enforced at binding time):

  • The element must be a custom element exposing static wcBindable with protocol: "wc-bindable" and version: 1
  • methodName must appear (by name) in wcBindable.commands
  • The bound value must be a CommandToken (assigning a non-token value throws — for example, an undeclared name like $command.typo resolves to undefined and is rejected here)

Token API

interface CommandToken {
  readonly name: string;
  readonly size: number;                            // current subscriber count
  subscribe(fn: (...args) => unknown): () => void;  // returns unsubscribe
  unsubscribe(fn: (...args) => unknown): boolean;
  emit(...args: unknown[]): unknown[];              // returns subscriber results in subscribe order
}

emit returns an array of return values from each subscriber (in subscribe order). For Promise-returning methods, wrap with Promise.all(token.emit(...)) to await all of them.

Subscription Lifecycle

  • The subscriber holds the element via WeakRef, so a removed element can still be garbage collected even while it remains in the token's subscriber set
  • On emit, if the WeakRef has been collected or the element is no longer connected (isConnected === false), the subscription is purged automatically (lazy purge)
  • When the owning <wcs-state> is disconnected, the entire token registry is cleared

The element's method is invoked with the arguments from emit:

this.$command.fetchUsers.emit(url, options);
// → element.fetch(url, options) on every subscriber

Emitting a Command from a DOM Event

A command token does not have to be emitted from state code. A DOM event binding can emit one directly by pointing its right-hand side at a $command.<name> path instead of a state method name:

<button data-wcs="onclick: $command.refreshList">Refresh</button>

| Form | Right-hand side | Behavior on event | |---|---|---| | onclick: someMethod | a state method name | state.someMethod(event, ...listIndexes) | | onclick: $command.someToken | a $command.<name> path | state.$command.someToken.emit(event, ...listIndexes) |

This is pure wiring: the event endpoint is connected to a command-token endpoint, with no logic in between. The emit arguments are passed through exactly like a handler call — the DOM Event first, then any enclosing list indexes — so subscribers receive (event, ...listIndexes). Inside a subscriber, pull what you need from the event (event.target.value, event.detail, …).

  • The right-hand side must be $command.<name> with <name> declared in $commandTokens. A path that does not resolve to a CommandToken (e.g. a typo) throws at event time.
  • Modifiers work unchanged: onclick#prevent: $command.someToken calls preventDefault() before emitting (#stop likewise).
  • This emits the same token the state emits, so element subscribers wired with command.<method>: $command.someToken receive it regardless of who pulled the trigger.
<!-- click fans the command out to every subscriber, no state method needed -->
<button data-wcs="onclick: $command.reset">Reset all</button>
<my-field data-wcs="command.clear: $command.reset"></my-field>
<my-list  data-wcs="command.reset: $command.reset"></my-list>

Event Token (Event Binding)

Command tokens push into a component (state invokes a method). Event tokens are the exact dual — they pull out of a component (an element dispatches an event, state receives it). Together they cover both directions of the element ↔ state boundary, and neither side ever holds a reference to the other — the token is the only shared object.

| Token | Direction | Subscribes | Emits | |---|---|---|---| | Command token | state → element | element (command.<method>:) | state ($command.<name>.emit) | | Event token | element → state | state ($on) | element (DOM event listener) |

  • The element wires eventToken.<property>: <tokenName> on a wc-bindable custom element.
  • State declares channels with $eventTokens and receives them with the $on map.
  • Subscribers are called as (state, event, ...listIndexes) — symmetric with the command-token emit convention.

Basic Usage

<wcs-state>
  <script type="module">
    export default {
      users: [],
      error: null,

      $eventTokens: ["userCreated", "createFailed"],
      $on: {
        userCreated(state, event) {
          state.users = state.users.concat(event.detail);
        },
        createFailed(state, event) {
          state.error = event.detail;
        }
      }
    };
  </script>
</wcs-state>

<!-- Emitters — must be wc-bindable custom elements -->
<my-form data-wcs="eventToken.created: userCreated; eventToken.error: createFailed"></my-form>

When <my-form> dispatches the DOM event mapped to its created property, the userCreated token fires and the $on.userCreated handler runs with (state, event).

$eventTokens Declaration

The $eventTokens array declares the channel names that eventToken.<prop>: bindings and $on keys may reference. Only declared names are valid (typo resistance).

export default {
  $eventTokens: ["userCreated", "createFailed"],
};
  • Entries must be non-empty strings
  • Duplicate entries throw an error at initialization
  • A token declared here but absent from $on simply has no subscriber — emitting it is a no-op

$on — Receiving on the State Side

$on maps each event-token name to a handler. Because state is passed as the first argument (not via this), handlers can be written as either method shorthand or arrow functions — this mirrors the command-token emit convention, where this is likewise not bound:

$on: {
  // both forms work — state is always the first parameter
  userCreated: (state, event) => { state.lastId = event.detail.id; },
  rowFailed(state, event, ...listIndexes) {
    const [i] = listIndexes;          // loop index when fired from inside a `for`
    state.failedRows = state.failedRows.concat(i);
  }
}
  • Every $on key must be declared in $eventTokens (otherwise an error is thrown at initialization)
  • Each value must be a function
  • The signature is (state, event, ...listIndexes) — the DOM Event first, then any enclosing loop indexes

eventToken.<property>: Binding

<my-target data-wcs="eventToken.error: createFailed"></my-target>

| Part | Description | |---|---| | eventToken. | Fixed prefix | | <property> | A wcBindable property name — not a raw DOM event name. The real event name is resolved from wcBindable.properties[].event | | <tokenName> | A bare event-token name declared in $eventTokens (no $-namespace prefix, unlike command tokens) |

The key is a property name rather than a raw event name so the binding goes through the same wcBindable contract that command bindings use — and so a namespaced event name (ns:evt) cannot collide with the binding's : separator. The framework looks up properties[].event and attaches a listener for that real event:

class MyTarget extends HTMLElement {
  static wcBindable = {
    protocol: "wc-bindable", version: 1,
    properties: [
      { name: "error",   event: "thing-error" },     // eventToken.error → listens for "thing-error"
      { name: "created", event: "thing-created" },
    ],
  };
}

Validation rules:

  • The element must be a wc-bindable custom element (static wcBindable, protocol: "wc-bindable", version: 1). A non-wc-bindable element is rejected at attach time.
  • <property> must appear in wcBindable.properties — checked at attach time (fail-fast; needs only the class, not DOM connection).
  • <tokenName> must be declared in $eventTokens — checked at fire time. State is resolved from the element's live root node when the event fires, so the binding also works inside for / if blocks and after SSR hydration, where the node may still be detached at attach time.
  • Modifiers #prevent / #stop work as on any event binding: eventToken.error#prevent: createFailed.

Inside a Loop

When the emitter sits inside a for block, the enclosing loop indexes are appended after the event, exactly like an on* handler:

<template data-wcs="for: rows">
  <my-row data-wcs="eventToken.failed: rowFailed"></my-row>
</template>
$on: {
  rowFailed(state, event, ...listIndexes) {
    const [i] = listIndexes;          // index of the row that fired
    state.failedRows = state.failedRows.concat(i);
  }
}

Fan-in and Chaining

Multiple elements can wire the same token (eventToken.x: shared) — every dispatch reaches the one $on handler, mirroring command-token fan-out. And because an $on handler receives state, it can re-emit a command token, chaining element → state → element:

$commandTokens: ["doRefresh"],
$eventTokens: ["completed"],
$on: {
  completed(state) {
    state.$command.doRefresh.emit();  // event in → command out
  }
}

Token API

Event tokens share the same Token pub/sub primitive as command tokens — name / size / subscribe / unsubscribe / emit, with subscribe-order preservation (see Token API). The token is resolved from the registry on every event so a re-setInitialState() rebuild still reaches the latest $on subscribers. When the owning <wcs-state> is disconnected, the event-token registry is cleared.

Inputs and Attribute Mirror

wcBindable.inputs declares one-way property inputs (state → element). When an entry sets attribute, the framework writes the value to that HTML attribute every time it writes the property, so attributeChangedCallback, CSS attribute selectors, and DevTools all stay in sync with the property value.

class MyChip extends HTMLElement {
  static wcBindable = {
    protocol: "wc-bindable", version: 1,
    properties: [],
    inputs: [
      { name: "data", attribute: "data" },        // property name === attribute name
      { name: "labelText", attribute: "label-text" }, // kebab-case mirror
      { name: "internal" },                       // no mirror, property-only
    ],
  };
}
<my-chip data-wcs="data: chip.payload; labelText: chip.title"></my-chip>

When state updates the value, both the property and the attribute are written:

chip.payload = { id: 1 }    → element.data = { id: 1 } and setAttribute("data", '{"id":1}')
chip.title   = "新着"        → element.labelText = "新着" and setAttribute("label-text", "新着")
chip.payload = null          → element.data = null and removeAttribute("data")

Attribute value encoding:

| Value type | Mirrored attribute | |---|---| | string / number / boolean / bigint | String(value) | | null / undefined | attribute removed | | object / array | JSON.stringify(value) (falls back to String(value) on circular references) |

Notes:

  • inputs entries without attribute are property-only — the value is written to the property but no attribute is touched
  • Mirror is best-effort: a setAttribute failure is swallowed (with a debug warning) and does not block the property write
  • Native HTML elements ignore inputs entirely — the mirror only activates for custom elements that expose static wcBindable

Declarative Custom Components (DCC)

Define custom elements entirely in HTML — no JavaScript class definition needed. Using data-wc-definition and Declarative Shadow DOM (<template shadowrootmode>), you can declare reusable components with reactive state inline.

Basic Definition

<!-- 1. Define the component (hidden by CSS) -->
<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>

<!-- 2. Use it — each instance gets its own state -->
<my-counter></my-counter>
<my-counter></my-counter>

When <wcs-state> detects it is inside a data-wc-definition host, it:

  1. Loads the state object (from <script type="module"> or src="*.js")
  2. Generates a custom element class with getter/setter/method properties on the prototype
  3. Registers it via customElements.define()

The definition element is hidden; each instance clones the template into its own Shadow DOM and initializes its own <wcs-state>.

Recommended CSS

:not(:defined) { display: none; }
[data-wc-definition] { display: none; }

$bindables and wc-bindable Protocol

The $bindables array declares which state properties are exposed as component properties with change events, following the wc-bindable protocol:

export default {
  count: 0,
  increment() { this.count++; },
  $bindables: ["count"]
};

This generates:

  • static wcBindable on the class — protocol metadata for framework adapters
  • Getter/setter on the prototype — reads/writes go through the reactive proxy
  • CustomEvent dispatch — my-counter:count-changed fires on every mutation

Binding to DCC Properties

Other <wcs-state> instances can bind to DCC properties just like any Web Component:

<my-counter data-wcs="count: parentCount"></my-counter>

<wcs-state>
  <script type="module">
    export default { parentCount: 0 };
  </script>
</wcs-state>
<div data-wcs="textContent: parentCount"></div>

Shadow Root Mode

Both open and closed modes are supported:

<my-component data-wc-definition>
  <template shadowrootmode="closed">
    <!-- closed shadow DOM -->
  </template>
</my-component>

Internal Properties

Properties prefixed with $ are internal and not exposed on the component prototype:

| Property | Purpose | |----------|---------| | $bindables | Declares observable properties | | $connectedCallback | Lifecycle hook (runs on each instance) | | $disconnectedCallback | Cleanup hook | | $updatedCallback | Called after state mutations |

SVG Support

All bindings work inside <svg> elements. Use attr.* 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>

Lifecycle Hooks

State objects can define $connectedCallback, $disconnectedCallback, and $updatedCallback for initialization, cleanup, and update lifecycle handling.

<wcs-state>
  <script type="module">
    export default {
      timer: null,
      count: 0,

      // Called when <wcs-state> is connected to the DOM
      async $connectedCallback() {
        const res = await fetch("/api/initial-count");
        this.count = await res.json();
        this.timer = setInterval(() => { this.count++; }, 1000);
      },

      // Called when <wcs-state> is disconnected from the DOM (sync only)
      $disconnectedCallback() {
        clearInterval(this.timer);
      }
    };
  </script>
</wcs-state>

| Hook | Timing | Async | |---|---|---| | $connectedCallback | After state initialization on first connect; on every reconnect thereafter | Yes (awaited) | | $disconnectedCallback | When the element is removed from the DOM | No (sync only) | | $updatedCallback(paths, indexesListByPath) | After state updates are applied | Yes (not awaited) |

All hooks except $disconnectedCallback support async — you can use async/await in any of them. Since the reactive proxy detects every property assignment as a change, standard async/await with direct property updates is sufficient for asynchronous operations — loading flags, fetched data, and error messages are all just property assignments, without requiring additional abstractions for async state management.

  • this inside hooks is the state proxy with full read/write access
  • $connectedCallback is called every time the element is connected (including re-insertion after removal), making it suitable for setup that should be re-established
  • $disconnectedCallback is called synchronously — use it for cleanup such as clearing timers, removing event listeners, or releasing resources
  • $updatedCallback(paths, indexesListByPath) receives the updated path list. For wildcard updates, indexesListByPath contains the updated index sets. Can be async, but the return value is not awaited
  • In Web Components, define async $stateReadyCallback(stateProp) to receive a hook when the bound state becomes available via bind-component

Configuration

Pass a partial configuration object to bootstrapState():

import { bootstrapState } from '@wcstack/state';

bootstrapState({
  locale: 'ja-JP',
  debug: true,
  enableMustache: false,
  tagNames: { state: 'my-state' },
});

All options with defaults:

| Option | Default | Description | |---|---|---| | bindAttributeName | 'data-wcs' | Binding attribute name | | tagNames.state | 'wcs-state' | State element tag name | | locale | 'en' | Default locale for filters | | debug | false | Debug mode | | enableMustache | true | Enable {{ }} syntax |

TypeScript Support

defineState() wraps your state object and provides type-safe this inside methods and getters — with zero runtime cost (identity function).

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";
  }
});

Utility types WcsPaths<T> and WcsPathValue<T, P> are also exported for advanced use cases. See docs/define-state.md for full documentation.

API Reference

bootstrapState()

Initialize the state system. Registers <wcs-state> custom element and sets up DOM content loaded handler.

import { bootstrapState } from '@wcstack/state';
bootstrapState();

<wcs-state> Element

| Attribute | Description | |---|---| | name | State name (default: "default") | | state | ID of a <script type="application/json"> element | | src | URL to .json or .js file | | json | Inline JSON string | | bind-component | Property name for web component binding |

IStateElement

| Property / Method | Description | |---|---| | name | State name | | initializePromise | Resolves when state is fully initialized | | listPaths | Set of paths used in for loops | | getterPaths | Set of paths defined as getters | | setterPaths | Set of paths defined as setters | | createState(mutability, callback) | Create a state proxy ("readonly" or "writable") | | createStateAsync(mutability, callback) | Async version of createState | | setInitialState(state) | Set state programmatically (before initialization) | | bindProperty(prop, descriptor) | Define a property on the raw state object | | nextVersion() | Increment and return version number |

Architecture

bootstrapState()
  └── registerComponents()              // Register <wcs-state> custom element

<wcs-state> connectedCallback
  ├── _initializeBindWebComponent()     // bind-component: get state from parent component
  ├── _initialize()                     // Load state (state attr / src / json / script / API)
  │     └── setStateElementByName()     // Register to WeakMap<Node, Map<name, element>>
  │           └── (first registration per rootNode)
  │                 └── queueMicrotask → buildBindings()
  ├── _callStateConnectedCallback()     // Call $connectedCallback if defined

buildBindings(root)
  ├── waitForStateInitialize()          // Wait for all <wcs-state> initializePromise
  ├── convertMustacheToComments()       // {{ }} → comment nodes
  ├── collectStructuralFragments()      // Collect for/if templates
  └── initializeBindings()              // Walk DOM, parse data-wcs, set up bindings

Reactivity Flow

  1. State changes via Proxy set trap → setByAddress()
  2. Address resolved → updater enqueues absolute address
  3. Dependency walker invalidates (dirties) downstream caches
  4. Updater applies changes to bound DOM nodes via applyChangeFromBindings()

State Address System

Paths like users.*.name are decomposed into:

  • PathInfo — static path metadata (segments, wildcard count, parent path)
  • ListIndex — runtime loop index chain
  • StateAddress — combination of PathInfo + ListIndex
  • AbsoluteStateAddress — state name + StateAddress (for cross-state references)

Server-Side Rendering

@wcstack/state supports SSR via the companion @wcstack/server package. The same templates you write for the client render on the server — no changes needed.

Quick Setup

  1. Add enable-ssr to your <wcs-state> element:
<wcs-state enable-ssr>
  <script type="module">
    export default {
      items: [],
      async $connectedCallback() {
        const res = await fetch("/api/items");
        this.items = await res.json();
      }
    };
  </script>
</wcs-state>
<template data-wcs="for: items">
  <div data-wcs="textContent: items.*.name"></div>
</template>
  1. Render on the server:
import { renderToString } from "@wcstack/server";

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

That's it. The client-side @wcstack/state automatically detects the <wcs-ssr> element, restores state from the JSON snapshot, and resumes reactivity without re-rendering.

How It Works

| Phase | What happens | |-------|--------