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

v3.2.0

Published

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

Readme

@wcstack/state

🤖 AI coding agents: This README is a package-level reference, not the primary entry point for building a wcstack application. If you have not already done so, first read the repository README and AGENTS.md, then use the wcstack-app skill.

This is not another convenient frontend framework. It brings a lineage established outside frontend development — the one where a path string is the contract between view and model — onto web standards.

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 (useState / useStore-style — the $connectedCallback lifecycle callbacks are not that)
  • 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.

Where It Sits — and When Not to Choose It

This is not React / Vue / Solid with a different syntax. Those put the coupling point between UI and state inside a component; this puts it in a path string. The premises are different, and a comparison only says something when it is made along the right axis.

| What component 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 reactive layer is added on top of web standards, and the page stays a page |

The nearer relatives are the attribute-directive, no-build libraries — Alpine.js, petite-vue and their kind. They share the premise (attributes on plain HTML, no compiler) and differ on two points that decide the choice:

  • No expression language. Those libraries put JavaScript expressions in attributes and evaluate them at runtime. data-wcs carries a path and a filter chain, nothing else; computation lives in path getters on the state. That is what lets a binding be checked statically (@wcstack/lint, the VS Code extension, @wcstack/typescript) and lets a page run under a strict CSP with no unsafe-eval (docs/csp.md).
  • It wires Web Components to each other. The wc-bindable, command-token and event-token protocols and bind-component mounts connect elements that never import one another. The I/O node packages are what that buys.

Choose it for HTML-first pages: server-rendered or static markup with reactive parts, a page composed from custom elements, anywhere "read the HTML and know every data dependency" matters and a build step is a cost rather than a given.

Do not choose it when the team already lives inside a component framework — use the I/O nodes through the framework adapters instead; when the hot path is a very large keyed list — Performance measures create / append at 2.5–3.5× @wcstack/signals, which interoperates with this package and is the better fit there; when templates need inline expressions — deliberately absent; or when the template must be type-checked by the compiler rather than by tooling — paths are strings, and @wcstack/typescript narrows that gap without closing it.

On those axes the comparison is concrete: the Performance section below is one, and the drivers under e2e/bench/ regenerate it on your own hardware.

The Lineage Outside JavaScript

The premise — a path string is the whole contract between a view and a model — is older than the framework era, and most of it was worked out outside JavaScript. Inside it, the direct ancestors are Knockout's data-bind="text: user.name" (an attribute carrying the binding, though it evaluates expressions and needs ko.observable wrappers) and Polymer's path system, which had dotted paths, items.* observers and this.set("users.0.name", v) — but required set() / notifyPath(), because plain assignment could not be observed on the platform of its time. Naming the older lineage is more useful than claiming novelty:

| Lineage | What it already had | What differs here | |---|---|---| | Spreadsheets (VisiCalc, 1979) | An address, a formula declaring what a cell is, a dependency graph, lazy recomputation — and no update code anywhere | Names instead of grid coordinates, and one formula per shape rather than per cell: get "cart.items.*.subtotal"() is not filled down into the rows; the wildcard is the definition | | Cocoa Bindings / KVC–KVO (NeXT's EOF, 1994; Mac OS X 10.3, 2003) | Key paths (person.address.street), a binding triple of target + key path + value transformer, and collection operators that aggregate along a path (@sum.items.price) | The triple lives in the markup instead of a nib or a bind:toObject:withKeyPath: call, so it can be grepped, linted and diffed. Change detection is an ES Proxy over plain objects rather than KVC compliance | | XForms (W3C Recommendation, 2003) | Model / instance / view separation, ref paths into the instance, and <bind calculate="…"> — a computed value declared at a path, the direct ancestor of a path getter | The path is an address and nothing else: the computation is a JavaScript getter on the state, not XPath inside an attribute. And it runs in a stock browser, with no XForms processor | | WPF / XAML (2006) | {Binding Path=User.Name, Mode=TwoWay}; a DataContext that re-roots a whole subtree; UpdateSourceTrigger choosing when the source is written; IValueConverter between the ends | One state tree per root, rather than a context inherited down the visual tree with RelativeSource / ElementName escapes — state: user is that re-rooting, written in the host's HTML. Converters are a closed set of 46 filters, not classes you register, and nothing is compiled | | Android Data Binding (2015) | The path in the layout file itself — android:text="@{user.name}", @={} for two-way | No build step and no generated binding class, and no expressions inside the attribute | | SCADA / HMI tag binding (industrial, decades) | Widget properties wired to tag paths (Line1/Tank/Level) by configuration alone, and indirect bindings that parameterize the path (Folder/Tag_{1}) so one screen drives many devices | The tree carries derived values, lists and mounted components, not a flat namespace of scalars; the parameter is a loop's wildcard, resolved by the row the binding sits in rather than assigned from a dropdown |

Wildcards also resemble MQTT topic filters (sensor/+/temperature) and OSC address patterns (/synth/*/freq), but those select messages in flight. items.*.price names state addresses, and the one string is both the subscription and the write target.

What survives the comparison as genuinely new is narrow: the wildcard path getter — a getter whose key is a path pattern, so one definition serves every row and the dependency edge is held per pattern instead of per cell. The rest is a recombination of the lineage above onto three things none of them could assume: Custom Elements, ES Proxy and Import Maps.

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 (state: path) | The mount table the host writes | Who mounted it, and what the rest of the tree holds |

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: no component code imports a reactive primitive or registers a subscription. A bind-component class still declares its own plain state object and reads it like a plain object — what never appears is glue that pulls state into the component.

  2. Component ↔ Component — The host mounts a subtree onto each component (<my-card data-wcs="state: user">), and volumes graft extra modules onto the tree (<wcs-state mount="i18n">). Components never import one another, and a whole-object mount is a path prefix on the single tree and nothing more. Two declarative forms reach further, each spelled out where it is defined: the per-property form (state.message: user.name) has the host name the component's own keys, and an exported getter lets the host read a value the component computes — that binding then depends on a component being mounted there.

  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 separates UI and state with no JavaScript intermediary. You can:

  • Redesign the UI without touching state logic — as far as the logic does not hang off what is rendered: a live binding is one of the three demand roots, so an element you think of as display-only can be the page's only subscription
  • Refactor state structure and only update path strings
  • Read the HTML and know every binding; the dependencies that are not in the HTML ($watch, $streams, $scan) are all declared in one place, the state

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

Every row is a section of this README. Unless it appears under Where the neighbours come in, it ships in this package.

| Area | In one line | Reference | |---|---|---| | Path model | Dot paths address state; * is an abstract index, ** a depth, $1 / $2 name the axes | First principle · Loop index variables | | Binding syntax | One data-wcs attribute carries property / text / class / style / attribute / event bindings; {{ }} in text nodes; the same inside <svg> | Binding syntax · Mustache · SVG | | Structural directives | for and if / elseif / else on <template> elements | Structural directives | | Row identity | Rows diff by reference, so a sort or a filter reuses the DOM; $listKeys keeps row DOM and row objects across refetched arrays | $listKeys | | Forms | Two-way binding for input / select / textarea, a radio group to one value, a checkbox group to an array, #ro / #onchange / #prevent / #stop | Two-way binding · Modifiers | | Filters | 46 built-ins, chainable, locale-aware formatting that reads <html lang> | Filters · Locale | | Derived state | Path getters declare virtual properties at any depth from one flat place; they chain, and they take setters | Path getters | | Aggregation and bulk write | $getAll / $setAll / $resolve read and write across items.*.price without rebuilding the array | Proxy APIs | | Recursive paths | $recursion declares where a tree's shape repeats; one ** getter covers every depth | Recursive paths | | Reactivity | An ES Proxy tracks reads per address, caches per address, invalidates in dependency order and batches DOM writes on a microtask | Updating state · Dependency tracking boundaries | | What makes a getter run | Getters are lazy. Demand comes from a live binding, a $watch or a $streams args — and from nowhere else | Demand roots | | Modularity | mount= grafts a module onto the one tree; state: path mounts a subtree onto a component; the per-property form maps single keys, and a mounted component's getters are exported at its mount point | Volumes · Whole-object mount | | Components | Two mutually exclusive mechanisms: a JavaScript class with bind-component, or HTML-only DCC | Choosing a mechanism | | Wiring to other elements | The wc-bindable protocol, spread (...: obj), #init= / #sync= authority, property-to-attribute mirroring | Binding authority · Spread · Inputs | | Tokens | Command tokens call an element's methods from state; event tokens carry the element's events back | Command token · Event token | | Time | $streams folds an async source, $watch reacts headlessly, $scan owns an accumulation that outlives both | Choosing a time mechanism | | Initialization and lifecycle | Six ways to supply the state; $connectedCallback$stateReadyCallback; bootstrapState() / createState() | State initialization · Lifecycle hooks · API reference | | Diagnostics | Unresolved paths, index arity, wildcard rank and getter cycles are reported; one failing binding stays confined, and neither values nor the DOM are rolled back | Diagnostics | | Delivery | Zero runtime dependencies, no build step, ESM, one CDN /auto tag; no unsafe-eval, Trusted Types supported | Installation · docs/csp.md |

Where the neighbours come in

@wcstack/state is the reactive core and nothing else. Tooling, I/O and routing live in sibling packages, and the split is always the same shape: this package provides the hook and the contract, the neighbour provides the machinery.

| Package | What it adds | What this package already provides | |---|---|---| | @wcstack/server | Renders the page on the server and hydrates the markup the client receives | The enable-ssr attribute and the hydration contract — SSR | | @wcstack/lint | npx @wcstack/lint <file> checks every data-wcs in an HTML file before it runs | The diagnostic codes and getWcsManifest(), both derived from this implementation — Diagnostics | | VS Code extension (wcstack-intellisense) | The same diagnostics, plus completion, inside the editor | The same manifest and codes | | @wcstack/typescript | wcs-schema carries the types into the HTML validator; wcs-tsc type-checks inline state scripts | defineState(), WcsPaths<T> / WcsPathValue<T, P>TypeScript support | | @wcstack/devtools | A browser panel over state, wiring and update history | The instrumentation the panel reads | | @wcstack/testing | mount() / settle() / fire() as one import | The bare recipes that need no extra package — Testing your page | | @wcstack/view-transition | Animates list moves, removals and branch swaps through the View Transition API | The transition-runner hand-off; with no arbiter on the page the mutation applies directly — Transition animations | | @wcstack/router · @wcstack/autoloader | Declarative routing; automatic loading of undefined custom elements | Paths a route can write into, and bindings that wait for a late definition | | The I/O nodesfetch, storage, ws, midi, … | The platform APIs as elements | The wc-bindable wiring, spread and the token protocols that connect them — Spread | | @wcstack/signals | A different reactive core, 2.5–3.5× faster on create / append for very large keyed lists | Interop — both speak wc-bindable, so the I/O nodes and DCCs are shared — Performance |

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>

Split entries (only the features you use)

@wcstack/state and /auto ship everything, and they stay the recommended way in: a page that uses every feature is smaller as one file than as core plus features. When a page deliberately leaves features out, it can compose them instead:

<script type="importmap">
{
  "imports": {
    "@wcstack/state/core": "https://cdn.jsdelivr.net/npm/@wcstack/[email protected]/dist/split/core.js",
    "@wcstack/state/features/temporal": "https://cdn.jsdelivr.net/npm/@wcstack/[email protected]/dist/split/features/temporal.js",
    "@wcstack/state/features/scopes": "https://cdn.jsdelivr.net/npm/@wcstack/[email protected]/dist/split/features/scopes.js"
  }
}
</script>
<script type="module">
  import { bootstrapState, installFeatures } from '@wcstack/state/core';
  import temporal from '@wcstack/state/features/temporal';  // $watch / $scan / $streams
  import scopes from '@wcstack/state/features/scopes';      // bind-component, mount=, DCC

  installFeatures([temporal, scopes]);
  bootstrapState();
</script>

Load the split form from the package's own files — jsDelivr's plain, version-pinned /npm/ path as above (it does not read exports, so name the file under dist/split/), or a bundler — and never through esm.run. Its +esm endpoint re-bundles every entry on the server and inlines the shared core chunk into each one, so every entry would carry its own engine: a feature would install into a copy the core never sees, and the page throws [wcs/feature-not-installed] in spite of installFeatures. From the plain files, every entry's relative import resolves to the same chunk URL, so the browser evaluates the engine once. Integrity for this form: docs/sri.md §5.1.

| Entry | What it adds | |---|---| | @wcstack/state/core | The binding engine: data-wcs, for / if, path getters, filters, events, $command / $on, bootstrapState, installFeatures | | @wcstack/state/features/temporal | $watch, $scan, $streams | | @wcstack/state/features/scopes | bind-component, mount= volumes, overlay exports, DCC (data-wc-definition) | | @wcstack/state/features/recursion | $recursion and ** paths | | @wcstack/state/features/ssr | enable-ssr: server rendering and hydration | | @wcstack/state/features/formats | The formatting filters (uc, date, round, truncate, …). The core answers only not, which if / else need | | @wcstack/state/features/devtools | The DevTools hook protocol source | | @wcstack/state/features/diagnostics | Development-time warnings: a bound / $watch / $scan path that does not resolve on the state is reported with a did-you-mean. Without it the page stays silent — thrown errors keep their full messages either way | | @wcstack/state/define | defineState and the types only — no runtime at all |

A declaration whose feature is missing does not fail quietly: it throws [wcs/feature-not-installed] … install it with installFeatures([...]) from "@wcstack/state/features/…" when the state is defined, and a filter with no implementation throws [wcs/filter-unknown] when the bindings are planned. Installing is idempotent, and every entry shares one core chunk (a feature never carries a second copy of the engine).

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

Under a Content-Security-Policy: form 5 (inline <script type="module">) is evaluated through a blob: URL and therefore requires script-src blob:. A page nonce does not cover it. If you enforce a strict CSP, use form 4 (src="./state.js") instead — it needs no extra directive. See docs/csp.md.

Mounting Additional State (mount=)

There is one state tree per root. To split state across modules, mount a volume: its data grafts onto the root tree at the mount path, and bindings read it by prefix.

<wcs-state mount="cart" src="./cart.js"></wcs-state>
<wcs-state src="./app.js"></wcs-state>

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

A volume may declare getters, $watch, $listKeys, $updatedCallback, and $connectedCallback/$disconnectedCallback — all relative to its mount path. $errorCallback is root-only (a binding failure is reported once, to the tree's owner). Load order does not matter (a volume connected before the root is grafted when the root registers). If the root <wcs-state> fails to initialize, the volumes already waiting for it settle with a report of their own instead of waiting forever. That report is the end of the line for those volumes: a volume reported as an orphan does not graft itself later, so connecting a corrected root afterwards does not bring it back. A volume that settles without grafting — orphaned, failed to load, or failed to graft — releases its mount slot, and so does a volume detached while it is still loading or waiting for its root. Such a volume takes the slot back when it is re-attached to the same root, or otherwise just before it grafts, and still grafts as before when the slot is free — even while detached; if another volume took the slot in the meantime, it reports that and does not graft. A synchronous throw from a volume's $connectedCallback is reported like an asynchronous one, and the volume counts as grafted. To recover without reloading the page, remove the broken root and the orphaned volumes and add new elements. A grafted volume keeps its slot even when detached, because its data stays in the tree. Mount paths must be static (*, $, #, @ are rejected). Changing mount after the element has initialized is not supported: the change is ignored with a console warning — remove the element and add a new one with the desired path.

Injecting root paths into a volume (3.1). When a volume's code needs a path outside its own subtree, write the injection on the volume element's data-wcs, in the same form as a component's partial mount:

<wcs-state mount="cart" src="./cart.js" data-wcs="state.taxRate: settings.taxRate"></wcs-state>

Inside cart.js — getters, methods, $watch, $listKeys and the lifecycle callbacks — this.taxRate reads and writes the root's settings.taxRate. The read records a dependency, so get total() re-evaluates when settings.taxRate changes. $watch: { taxRate() {…} } fires on changes to settings.taxRate, and $updatedCallback receives that update under the inner name taxRate. The injected name exists only inside the volume's code: the page keeps reading settings.taxRate, and the tree has no cart.taxRate.

  • One key at a time. The left side is a single state.<key>. state: … cannot move the whole volume; change mount for that.
  • The injection wins over the volume's own key. A data key of the same name (taxRate: 0) is not grafted. An accessor or method of the same name would make this.taxRate ambiguous, so that volume reports an error and does not graft.
  • #ro. With state.taxRate#ro: settings.taxRate, the value can be read but not written. An assignment from the volume's code, $setAll, or a writing $resolve throws [wcs/mount-readonly]; the root itself can still write the path.
  • Static paths only. The right side cannot contain a wildcard (items.*.x) or start with $, because a volume has no loop context. Filters are not accepted either; derive the value in a getter. A malformed injection is reported as [wcs/mount-path-invalid] before the volume loads.

What each scope runs (3.0 states it as one table — requirement B11; nothing here is ignored silently):

| Declaration | Root <wcs-state> | Volume <wcs-state mount="p"> | Mounted component (bind-component with state: …) | |---|---|---|---| | Data keys, getters, setters, methods | yes | yes, relative to p | yes — own keys are private unless the host maps them; getters are exported | | $connectedCallback / $disconnectedCallback | yes | yes, relative to p | yes | | $watch, $listKeys, $updatedCallback | yes | yes, relative to p | not run — one wcs/mount-dollar-declaration warning | | $streams, $scan, $recursion, ** getters | yes | rejected with an error before grafting | not run — warning | | $commandTokens, $eventTokens, $on | yes | not run — warning | not run — warning | | $errorCallback | yes | not run — warning (silent before 3.0) | not run — warning (silent before 3.0) | | An initialization failure | reported once; connectedCallbackPromise rejects | settles without grafting and releases its slot; connectedCallbackPromise resolves (a missing scopes feature rejects it) | reported once; the component's connectedCallbackPromise rejects |

A component that is not mounted (a plain Shadow DOM child with its own <wcs-state>) is a root of its own and runs everything in the first column.

Migrating from v1's named states: <wcs-state name="cart"> + total@cart becomes <wcs-state mount="cart"> + cart.total. In v2 the name attribute fails fast and @ in a path is a parse error, each with this exact guidance. Migration table: docs/state-mount-design.md §9.

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[|filter[|filter(args)...]]

Multiple bindings separated by ;:

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

The separators ; and | split only outside quotes (3.0), so a quoted filter argument may contain them: textContent: tags|join('; '), title: parts|join(' | '). Before 3.0 both broke the binding.

| 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 | | \|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 | | .NAME | Explicit property (3.1): never an event, even when the name starts with on |

Properties whose names start with on (3.1). A binding whose name starts with on is an event binding (onclick:). So online: x listens for a "line" event and never writes the element's online property. To bind such a property, put a dot in front of the name:

<my-status data-wcs=".online: isOnline; onclick: refresh"></my-status>

The dotted form is the same binding as the undotted one, except that it is never an event. .value: is two-way like value:, and modifiers and input filters work as usual. A namespace word (.class, .attr, .style, .command, .eventToken) or an empty name after the dot is rejected with [wcs/binding-syntax].

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 | | #init=<authority> | Binding authority / initial sync direction — see Binding Authority | | #sync=<timing> | Element snapshot timing — see Binding Authority |

Multiple modifiers are comma-separated after a single #: value#ro,init=none: path.

A modifier never changes the kind of binding: radio#ro: and checkbox#ro: stay radio / checkbox bindings (3.0). Before 3.0 a modifier turned them into a plain property named radio, so #ro on a radio group did not take effect.

As of 3.0 the parser rejects what it used to round off silently, with [wcs/binding-syntax]: a second # (value#ro#wo kept ro and dropped the rest — write value#ro,wo), a value after else: (write else:), modifiers or left-side filters on for / if / elseif / else / ..., and an unterminated quote in filter arguments.

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.

Binding Authority (#init= / #sync=)

The problem this solves. An element that already holds a value when its binding attaches — <wcs-storage> after loading a persisted value, a clock, a widget restoring its own snapshot — is overwritten by the state seed, because the initial sync of a two-way binding writes state→element. Adding #init=element to that one binding makes the element win the initial sync instead; later changes flow both ways as usual. That case (load-before-bind) is spelled out below; the rest of this section is the general rule it is an instance of.

For custom elements that declare static wcBindable, every prop binding resolves an authority — which side wins the initial sync when the binding attaches. The steady-state direction is decided separately, by the member's declared shape: an output-only member never accepts state writes (a permanent contract), while a two-way member flows both ways after the initial sync regardless of which side won it. The default authority is derived from where the member is declared (on by default via enableDirectionalInitialSync):

| Member declared in | Default authority | Effect | |---|---|---| | properties only (output-only) | element | The element's value flows into state; state never writes this member | | inputs only | state | State writes the element | | properties + inputs (two-way) | state | Classic behavior — state writes first, element events update state afterwards | | — (no wcBindable; plain HTML elements) | state | Unchanged behavior |

Authoring rule: declare every settable member in both properties and inputs. A member declared only in properties is output-only — state→element writes are suppressed for the life of the binding, and the element's own initial value overwrites whatever the state seeded. (@wcstack I/O node Shells and DCC $bindables follow this rule.)

What the element writes back (properties[].getter)

When the element dispatches properties[].event, the value written to state is getter(event). With no getter, the protocol default applies — (e) => e.detail: the whole detail, as-is. The declared property is not read off the element at that point; the event payload is authoritative. A plain HTML element (no wcBindable) is the other way round: element[propName] is read on input/change.

So an element that dispatches detail: { value: 7654321 } without a getter writes the object { value: 7654321 } to state, not the number — and the failure is mostly silent: the write-back (Number({ value: … })NaN) throws nothing, and @wcstack/lint cannot see it (the payload shape is not static). The runtime warns once per element and property (wcs/default-getter-mismatch) for the two shapes it can tell apart at the event: a detail that is undefined while the element property has a value (a plain Event, or a forgotten detail), and a detail object carrying a <propName> key while the property is not an object (the wrapper above). Any other mismatch goes through unnoticed, and the write is applied as-is either way. Use one of the two conforming shapes:

class YenInput extends HTMLElement {
  static wcBindable = {
    protocol: "wc-bindable", version: 1,
    properties: [
      // (a) the value itself is the detail — the protocol's recommendation; no getter needed
      { name: "value", event: "yen-input:value-changed" },
      // (b) the detail is an object, or the event is not a CustomEvent — say how to read it
      // { name: "value", event: "yen-input:value-changed", getter: (e) => e.detail.value },
      // { name: "value", event: "input",                  getter: (e) => e.target.value },
    ],
    inputs: [{ name: "value" }],
  };
  #onInput() {
    // (a): dispatch the value, not a wrapper object
    this.dispatchEvent(new CustomEvent("yen-input:value-changed", { detail: this.value, bubbles: true }));
  }
}

Whichever you pick, element.value and the value extracted from the event must be the same logical state (the protocol's Producer State Consistency Invariant): the initial sync reads the property, every later update reads the event. Both shapes are in use inside wcstack — <wcs-fetch>'s loading dispatches the boolean as detail with no getter, its value reads detail.value through one — and DCC $bindables declare getter: (e) => e.target[name] because a sub-path write has no single value to put in detail. The default itself is not going to change: it is normative for every wc-bindable adapter (@wc-bindable/core's bind() and the framework adapters implement the same e.detail), and the protocol classes a different default as a breaking change requiring a new protocol identifier.

Override the authority per binding with #init=:

| Value | Initial sync | Allowed on | |---|---|---| | init=state | The state value is written to the element (two-way default) | inputs-only, two-way | | init=element | The element's snapshot seeds the state instead — on a two-way member the binding then continues as normal two-way (state→element writes flow from the next change on) | output-only, two-way | | init=auto | element if the state slot is uninitialized, otherwise state | two-way | | init=none | No initial sync — changes flow normally from the next update (event bindings accept only this value) | any |

#init= decides only who wins the initial race. The permanent suppression of state→element writes comes from the member being declared output-only, never from the modifier. This makes #init=element (or #init=auto) the declarative fix for load-before-bind: an element that loads a persisted value in its own connectedCallback — before the binding attaches — is no longer clobbered by the state seed, and later state changes still reach the element (so e.g. <wcs-storage> keeps saving):

<!-- The persisted list seeds `todos`; assigning `todos` later still saves. -->
<wcs-storage key="todos" type="local" data-wcs="value#init=element: todos"></wcs-storage>

#sync= controls when the element snapshot is read for element-authority bindings:

| Value | Meaning | |---|---| | sync=call (default) | Read immediately when the binding attaches | | sync=connect | Defer the read until the element is connected to the document |

<x-clock  data-wcs="value#init=element: clock.now"></x-clock>
<x-input  data-wcs="value#init=auto: form.name"></x-input>
<x-widget data-wcs="value#init=element,sync=connect: widget.snapshot"></x-widget>

With sync=connect, state→element writes stay suppressed until the connect snapshot has resolved the initial race.

Notes:

  • With enableDirectionalInitialSync: false (opt-out), writing #init=/#sync= throws.
  • Migrating from ≤ 1.20: do not seed state with placeholder values (value: [], query: "") for output-only members — the element's real initial value (often null/undefined) replaces the seed. Match the seed to the element's actual initial value and null-guard display values with a derived getter.
  • Until 1.21.x, init=element / init=auto / init=none suppressed state→element writes for the binding's whole lifetime, which made them unusable on genuinely two-way members. Authority now governs only the initial sync (docs/architecture-hardening/09-remediation-design.md §3.6).

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 that feeds an element input, not just spread; with config.debug each skipped write is logged via console.debug.) Display surfaces are different (3.0): textContent / innerText / innerHTML, mustache text, attr.* and style.* have no element default worth keeping, so undefined and null both mean "no value" there — the text becomes empty and the attribute or style is removed. Before 3.0 a textContent: binding skipped undefined too, which left the previous row's text in a reused list row, and an attribute got the string "undefined" / "null".

Constraints:

  • Filters on the spread target (...: target|filter) are rejected.
  • The right-hand path may contain * anywhere (e.g. ...: stores.*.fetch).
  • The right-hand side is a plain tree path (...: fetchX or ...: stores.*.fetch).
  • 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. 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.

This means no explicit key attribute is needed for adding, removing, or reordering rows (like React's key or Vue's :key) — as long as row objects keep their references. Non-destructive array methods (toSorted, toReversed, filter, with, toSpliced) all preserve element references, so sorting and filtering are keyed by construction, and the whole class of "wrong key" bugs cannot occur.

The exception is data that arrives as freshly created objectsfetch(...).json(), JSON.parse from storage, a WebSocket/SSE full snapshot, or a Worker postMessage. Those rows never match by reference, so every row is torn down and rebuilt. See $listKeys below.

$listKeys — identity for refetched rows

When rows carry DOM state the bindings do not own — focus, an in-flight IME composition, <details> open state, inner scroll position, <canvas> contents, <video> playback — rebuilding the rows loses it. Declare a key so the framework can recognize rows across a refresh:

{
  items: [],
  $listKeys: {
    "items": "id",                        // field name
    "items.*.children": (row) => row.uid, // or a function, for composite keys
  },
}

With a key declared, assigning a new array keeps the existing row objects and writes only the fields that actually changed into them. The row's DOM is reused rather than rebuilt:

// Every row object is new, but rows are matched by id — DOM, focus and
// <details> state survive, and only the fields that differ are written.
this.items = await (await fetch("/api/items")).json();

Notes:

  • Opt-in and per-path. Lists without a declaration behave exactly as before, at no cost.
  • Nesting is opt-in too. Only declared paths are matched by key; undeclared nested arrays are replaced by reference as usual. This lets you adopt it one list at a time.
  • A no-op refresh is free. If nothing changed, no field is written and no DOM work happens at all.
  • Rows must be plain objects, and keys must be present and unique. Duplicate keys, missing keys, and class instances raise an error immediately rather than degrading silently.
  • Fields dropped from a row are cleared with null, which is this package's vocabulary for an explicit clear (undefined means "the state has no opinion" and skips the write).
  • The stored array is rebuilt from matched row objects, so this.items !== theArrayYouAssigned afterwards.

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 |

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. `indexes` omitted: it defaults to
  // the loop context ([$1, $2]), so only this prefecture's cities are summed
  get "regions.*.prefectures.*.totalPopulation"() {
    return this.$getAll("regions.*.prefectures.*.cities.*.population")
      .reduce((a, b) => a + b, 0);
  },

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

  // Top level — no loop context; [] means "every match"
  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.

Getters must be pure with respect to state

A getter's cache is invalidated only through the dependency graph, and the graph only records what the getter read through this. Anything else a getter reads is invisible to invalidation, so the first value computed is the value you keep:

// ❌ Never recomputes — nothing in the dependency graph ever changes
get stamp() { return `${this.label} @ ${Date.now()}`; }   // Date.now() is untracked
get theme() { return document.body.dataset.theme; }        // the DOM is untracked
get total() { return this.price * exchangeRate; }          // a module variable is untracked

The rule: read only through this, and don't write state or touch the DOM from a getter. For the cases where an untracked input genuinely has to participate, put the input into state and assign to it (the normal path-assignment contract), or use the escape hatches:

| API | Use it for | |---|---| | this.$trackDependency(path) | Register an extra dependency so this getter is dirtied when that path changes | | this.$postUpdate(path) | Announce that an untracked input changed, from outside the getter | | this.$untrackDependency(fn) | Read a path without registering it as a dependency (the inverse) | | this.$eq(path, key) / $eqPath(path, keyPath) / $eqIndex(path) | A keyed subscription: "is path equal to this row's key?" without a pattern dependency (see Keyed selection) |

// ✅ The clock ticks in state; the getter stays pure
export default {
  now: Date.now(),
  get stamp() { return `${this.label} @ ${this.now}`; },
  $connectedCallback() { setInterval(() => { this.now = Date.now(); }, 1000); },
};

Getters that throw are not swallowed: the exception surfaces where the getter was evaluated (a binding apply, a $watch evaluation, or your own read).

Dependency tracking boundaries

Three rules decide what the dependency graph sees. None of them matters until you cross one, and when you do the symptom is a value that stops updating with no error — so they are collected here:

| Rule | What it looks like when crossed | |---|---| | Only path reads through this are tracked. this.form tracks form; this["form.name"] tracks form.name; this.form.name tracks form only — the .name is a plain property access on the object that came back. Date.now(), the DOM, a module variable, a closed-over object register nothing | The getter is never re-evaluated for that input; the first value sticks (the examples above). A getter that reads this.form.name does not re-run when a bound <input data-wcs="value: form.name"> changes — read this["form.name"] | | Reads inside a setter are not tracked. A setter is an imperative assignment, not a derivation, so nothing it reads becomes a dependency of anything | A setter that reads this.a to decide what to write does not run again when a changes — only a getter re-runs | | The same-value guard applies to primitives only. A primitive wri