@wcstack/state 1.8.6 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,1350 +1,1488 @@
1
- # @wcstack/state
2
-
3
- **What if HTML had reactive data binding?**
4
-
5
- Imagine a future where the browser natively understands state — you declare data inline, bind it to the DOM with attributes, and everything stays in sync. No virtual DOM, no compilation, no framework. Just HTML that reacts.
6
-
7
- That's what `<wcs-state>` and `data-wcs` explore. One CDN import, zero dependencies, pure HTML syntax.
8
-
9
- 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.
10
-
11
- ## Design Philosophy
12
-
13
- ### Path as the Universal Contract
14
-
15
- 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.
16
-
17
- `@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:
18
-
19
- | Layer | What it knows | What it doesn't know |
20
- |-------|---------------|----------------------|
21
- | **State** (`<wcs-state>`) | Data structure and business logic | Which DOM nodes are bound |
22
- | **UI** (`data-wcs`) | Path strings and display intent | How state is stored or computed |
23
- | **Components** (`@name`) | The path they need from a named state | The other component's internals |
24
-
25
- Three levels of path contracts keep everything loosely coupled:
26
-
27
- 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.
28
-
29
- 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.
30
-
31
- 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.
32
-
33
- ### Why This Matters
34
-
35
- This is complete separation of UI and state with **no JavaScript intermediary**. You can:
36
-
37
- - Redesign the entire UI without touching state logic
38
- - Refactor state structure and only update path strings
39
- - Read the HTML alone and understand every data dependency
40
-
41
- 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.
42
-
43
- ## 4 Steps to Reactive HTML
44
-
45
- ```html
46
- <!-- 1. Load the CDN -->
47
- <script type="module" src="https://esm.run/@wcstack/state/auto"></script>
48
-
49
- <!-- 2. Write a <wcs-state> tag -->
50
- <wcs-state>
51
- <!-- 3. Define your state object -->
52
- <script type="module">
53
- export default {
54
- message: "Hello, World!"
55
- };
56
- </script>
57
- </wcs-state>
58
-
59
- <!-- 4. Bind with data-wcs attributes -->
60
- <div data-wcs="textContent: message"></div>
61
- ```
62
-
63
- That's it. No build, no bootstrap code, no framework.
64
-
65
- ## Features
66
-
67
- - **Declarative data binding** `data-wcs` attribute for property / text / event / structural binding
68
- - **Reactive Proxy** — ES Proxy-based automatic DOM updates with dependency tracking
69
- - **Structural directives** `for`, `if` / `elseif` / `else` via `<template>` elements
70
- - **Built-in filters** — 40 filters for formatting, comparison, arithmetic, date, and more
71
- - **Two-way binding** — automatic for `<input>`, `<select>`, `<textarea>`
72
- - **Web Component binding** bidirectional state binding with Shadow DOM components
73
- - **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
74
- - **Mustache syntax** — `{{ path|filter }}` in text nodes
75
- - **Multiple state sources** JSON, JS module, inline script, API, attribute
76
- - **SVG support** — full binding support inside `<svg>` elements
77
- - **Lifecycle hooks** `$connectedCallback` / `$disconnectedCallback` / `$updatedCallback`, plus `$stateReadyCallback` for Web Components
78
- - **TypeScript support** — `defineState()` for typed state definitions with dot-path autocompletion ([details](docs/define-state.md))
79
- - **Server-Side Rendering** — `enable-ssr` attribute + `@wcstack/server` for full SSR with automatic hydration
80
- - **Zero dependencies** — no runtime dependencies
81
-
82
- ## Installation
83
-
84
- ### CDN (recommended)
85
-
86
- ```html
87
- <!-- Auto-initialization — this is all you need -->
88
- <script type="module" src="https://esm.run/@wcstack/state/auto"></script>
89
- ```
90
-
91
- ### CDN (manual initialization)
92
-
93
- ```html
94
- <script type="module">
95
- import { bootstrapState } from 'https://esm.run/@wcstack/state';
96
- bootstrapState();
97
- </script>
98
- ```
99
-
100
- ## Basic Usage
101
-
102
- ```html
103
- <wcs-state>
104
- <script type="module">
105
- export default {
106
- count: 0,
107
- user: { id: 1, name: "Alice" },
108
- users: [
109
- { id: 1, name: "Alice" },
110
- { id: 2, name: "Bob" },
111
- { id: 3, name: "Charlie" }
112
- ],
113
- countUp() { this.count += 1; },
114
- clearCount() { this.count = 0; },
115
- get "users.*.displayName"() {
116
- return this["users.*.name"] + " (ID: " + this["users.*.id"] + ")";
117
- }
118
- };
119
- </script>
120
- </wcs-state>
121
-
122
- <!-- Text binding -->
123
- <div data-wcs="textContent: count"></div>
124
- {{ count }}
125
-
126
- <!-- Two-way input binding -->
127
- <input type="text" data-wcs="value: user.name">
128
-
129
- <!-- Event binding -->
130
- <button data-wcs="onclick: countUp">Increment</button>
131
-
132
- <!-- Conditional class -->
133
- <div data-wcs="textContent: count; class.over: count|gt(10)"></div>
134
-
135
- <!-- Loop -->
136
- <template data-wcs="for: users">
137
- <div>
138
- <span data-wcs="textContent: .id"></span>:
139
- <span data-wcs="textContent: .displayName"></span>
140
- </div>
141
- </template>
142
-
143
- <!-- Conditional rendering -->
144
- <template data-wcs="if: count|gt(0)">
145
- <p>The count is positive.</p>
146
- </template>
147
- <template data-wcs="elseif: count|lt(0)">
148
- <p>The count is negative.</p>
149
- </template>
150
- <template data-wcs="else:">
151
- <p>The count is zero.</p>
152
- </template>
153
- ```
154
-
155
- ## State Initialization
156
-
157
- `<wcs-state>` supports multiple ways to load initial state:
158
-
159
- ```html
160
- <!-- 1. Reference a <script type="application/json"> by id -->
161
- <script type="application/json" id="state">
162
- { "count": 0 }
163
- </script>
164
- <wcs-state state="state"></wcs-state>
165
-
166
- <!-- 2. Inline JSON attribute -->
167
- <wcs-state json='{ "count": 0 }'></wcs-state>
168
-
169
- <!-- 3. External JSON file -->
170
- <wcs-state src="./data.json"></wcs-state>
171
-
172
- <!-- 4. External JS module (export default { ... }) -->
173
- <wcs-state src="./state.js"></wcs-state>
174
-
175
- <!-- 5. Inline script module -->
176
- <wcs-state>
177
- <script type="module">
178
- export default { count: 0 };
179
- </script>
180
- </wcs-state>
181
-
182
- <!-- 6. Programmatic API -->
183
- <script>
184
- const el = document.createElement('wcs-state');
185
- el.setInitialState({ count: 0 });
186
- document.body.appendChild(el);
187
- </script>
188
- ```
189
-
190
- Resolution order: `state` → `src` (.json / .js) → `json` → inner `<script>` → wait for `setInitialState()`.
191
-
192
- ### Named State
193
-
194
- Multiple state elements can coexist with the `name` attribute. Bindings reference them with `@name`:
195
-
196
- ```html
197
- <wcs-state name="cart">...</wcs-state>
198
- <wcs-state name="user">...</wcs-state>
199
-
200
- <div data-wcs="textContent: total@cart"></div>
201
- <div data-wcs="textContent: name@user"></div>
202
- ```
203
-
204
- Default name is `"default"` (no `@` needed).
205
-
206
- ## Updating State
207
-
208
- In `@wcstack/state`, every piece of state has a **path** — like `count`, `user.name`, or `items`. To update state reactively, **assign to the path**:
209
-
210
- ```javascript
211
- this.count = 10; // path "count"
212
- this["user.name"] = "Bob"; // path "user.name"
213
- ```
214
-
215
- That's the one rule: **assign to the path, and the DOM updates automatically.**
216
-
217
- ### Why `this.user.name = "Bob"` Doesn't Work
218
-
219
- `this.user.name` first reads the `user` object via `this.user` (a path read), then sets `.name` on that plain object — this is not a path assignment, so the change is not detected:
220
-
221
- ```javascript
222
- // ✅ Path assignment — change detected
223
- this["user.name"] = "Bob";
224
-
225
- // ❌ Not a path assignment — change NOT detected
226
- this.user.name = "Bob";
227
- ```
228
-
229
- ### Arrays
230
-
231
- 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:
232
-
233
- ```javascript
234
- // ✅ New array assigned to path — change detected
235
- this.items = this.items.concat({ id: 4, text: "New" });
236
- this.items = this.items.toSpliced(index, 1);
237
- this.items = this.items.filter(item => !item.done);
238
- this.items = this.items.toSorted((a, b) => a.id - b.id);
239
- this.items = this.items.toReversed();
240
- this.items = this.items.with(index, newValue);
241
-
242
- // In-place mutation no path assignment, change NOT detected
243
- this.items.push({ id: 4, text: "New" });
244
- this.items.splice(index, 1);
245
- this.items.sort((a, b) => a.id - b.id);
246
- ```
247
-
248
- ## Binding Syntax
249
-
250
- ### `data-wcs` Attribute
251
-
252
- ```
253
- property[#modifier]: path[@state][|filter[|filter(args)...]]
254
- ```
255
-
256
- Multiple bindings separated by `;`:
257
-
258
- ```html
259
- <div data-wcs="textContent: count; class.over: count|gt(10)"></div>
260
- ```
261
-
262
- | Part | Description | Example |
263
- |---|---|---|
264
- | `property` | DOM property to bind | `value`, `textContent`, `checked` |
265
- | `#modifier` | Binding modifier | `#ro`, `#prevent`, `#stop`, `#onchange` |
266
- | `path` | State property path | `count`, `user.name`, `users.*.name` |
267
- | `@state` | Named state reference | `@cart`, `@user` |
268
- | `\|filter` | Transform filter chain | `\|gt(0)`, `\|round\|locale` |
269
-
270
- ### Property Types
271
-
272
- | Property | Description |
273
- |---|---|
274
- | `value` | Element value (two-way for inputs) |
275
- | `checked` | Checkbox / radio checked state (two-way) |
276
- | `textContent` | Text content |
277
- | `text` | Alias for textContent |
278
- | `html` | innerHTML |
279
- | `class.NAME` | Toggle a CSS class |
280
- | `style.PROP` | Set a CSS style property |
281
- | `attr.NAME` | Set an attribute (supports SVG namespace) |
282
- | `radio` | Radio button group binding (two-way) |
283
- | `checkbox` | Checkbox group binding to array (two-way) |
284
- | `onclick`, `on*` | Event handler binding |
285
-
286
- ### Modifiers
287
-
288
- | Modifier | Description |
289
- |---|---|
290
- | `#ro` | Read-only disables two-way binding |
291
- | `#prevent` | Calls `event.preventDefault()` on event handlers |
292
- | `#stop` | Calls `event.stopPropagation()` on event handlers |
293
- | `#onchange` | Uses `change` event instead of `input` for two-way binding |
294
-
295
- ### Two-Way Binding
296
-
297
- Automatically enabled for:
298
-
299
- | Element | Property | Event |
300
- |---|---|---|
301
- | `<input type="checkbox/radio">` | `checked` | `input` |
302
- | `<input>` (other types) | `value`, `valueAsNumber`, `valueAsDate` | `input` |
303
- | `<select>` | `value` | `change` |
304
- | `<textarea>` | `value` | `input` |
305
-
306
- `<input type="button">` is excluded. Use `#ro` to disable, `#onchange` to change the event.
307
-
308
- ### Radio Binding
309
-
310
- Bind a radio button group to a single state value with `radio`:
311
-
312
- ```html
313
- <input type="radio" value="red" data-wcs="radio: selectedColor">
314
- <input type="radio" value="blue" data-wcs="radio: selectedColor">
315
- ```
316
-
317
- 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.
318
-
319
- Inside a `for` loop:
320
-
321
- ```html
322
- <template data-wcs="for: branches">
323
- <label>
324
- <input type="radio" data-wcs="value: .; radio: currentBranch">
325
- {{ . }}
326
- </label>
327
- </template>
328
- ```
329
-
330
- ### Checkbox Binding
331
-
332
- Bind a checkbox group to a state array with `checkbox`:
333
-
334
- ```html
335
- <input type="checkbox" value="apple" data-wcs="checkbox: selectedFruits">
336
- <input type="checkbox" value="banana" data-wcs="checkbox: selectedFruits">
337
- <input type="checkbox" value="orange" data-wcs="checkbox: selectedFruits">
338
- ```
339
-
340
- 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.
341
-
342
- ### Mustache Syntax
343
-
344
- When `enableMustache` is `true` (default), `{{ expression }}` in text nodes is supported:
345
-
346
- ```html
347
- <p>Hello, {{ user.name }}!</p>
348
- <p>Count: {{ count|locale }}</p>
349
- ```
350
-
351
- Internally converted to comment-based bindings (`<!--@@:expression-->`).
352
-
353
- ## Structural Directives
354
-
355
- Structural directives use `<template>` elements:
356
-
357
- ### Loop (`for`)
358
-
359
- ```html
360
- <template data-wcs="for: users">
361
- <div>
362
- <!-- Full path -->
363
- <span data-wcs="textContent: users.*.name"></span>
364
- <!-- Shorthand (relative to loop context) -->
365
- <span data-wcs="textContent: .name"></span>
366
- </div>
367
- </template>
368
- ```
369
-
370
- 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.
371
-
372
- #### Dot Shorthand
373
-
374
- Inside a `for` loop, paths starting with `.` are expanded relative to the loop's array path:
375
-
376
- | Shorthand | Expanded to | Description |
377
- |---|---|---|
378
- | `.name` | `users.*.name` | Property of the current element |
379
- | `.` | `users.*` | The current element itself |
380
- | `.name\|uc` | `users.*.name\|uc` | Filters are preserved |
381
- | `.name@state` | `users.*.name@state` | State name is preserved |
382
-
383
- For primitive arrays, `.` refers to the element value directly:
384
-
385
- ```html
386
- <template data-wcs="for: branches">
387
- <label>
388
- <input type="radio" data-wcs="value: .; radio: currentBranch">
389
- {{ . }}
390
- </label>
391
- </template>
392
- ```
393
-
394
- Nested loops are supported with multi-level wildcards. The `.` shorthand in nested `for` directives also expands relative to the parent loop path:
395
-
396
- ```html
397
- <template data-wcs="for: regions">
398
- <!-- .states → regions.*.states -->
399
- <template data-wcs="for: .states">
400
- <!-- .name → regions.*.states.*.name -->
401
- <span data-wcs="textContent: .name"></span>
402
- </template>
403
- </template>
404
- ```
405
-
406
- ### Conditional (`if` / `elseif` / `else`)
407
-
408
- ```html
409
- <template data-wcs="if: count|gt(0)">
410
- <p>Positive</p>
411
- </template>
412
- <template data-wcs="elseif: count|lt(0)">
413
- <p>Negative</p>
414
- </template>
415
- <template data-wcs="else:">
416
- <p>Zero</p>
417
- </template>
418
- ```
419
-
420
- Conditions can be chained. `elseif` automatically inverts the previous condition.
421
-
422
- ## Path Getters (Computed Properties)
423
-
424
- **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.
425
-
426
- ### Basic Path Getter
427
-
428
- ```html
429
- <wcs-state>
430
- <script type="module">
431
- export default {
432
- users: [
433
- { id: 1, firstName: "Alice", lastName: "Smith" },
434
- { id: 2, firstName: "Bob", lastName: "Jones" }
435
- ],
436
- // Path getter — runs per-element inside a loop
437
- get "users.*.fullName"() {
438
- return this["users.*.firstName"] + " " + this["users.*.lastName"];
439
- },
440
- get "users.*.displayName"() {
441
- return this["users.*.fullName"] + " (ID: " + this["users.*.id"] + ")";
442
- }
443
- };
444
- </script>
445
- </wcs-state>
446
-
447
- <template data-wcs="for: users">
448
- <div data-wcs="textContent: .displayName"></div>
449
- </template>
450
- <!-- Output:
451
- Alice Smith (ID: 1)
452
- Bob Jones (ID: 2)
453
- -->
454
- ```
455
-
456
- Inside a path getter, `this["users.*.firstName"]` automatically resolves to the current loop element — no manual indexing needed.
457
-
458
- ### Top-Level Computed Properties
459
-
460
- Getters without wildcards work as standard computed properties:
461
-
462
- ```javascript
463
- export default {
464
- price: 100,
465
- tax: 0.1,
466
- get total() {
467
- return this.price * (1 + this.tax);
468
- }
469
- };
470
- ```
471
-
472
- ### Getter Chaining
473
-
474
- Path getters can reference other path getters, forming a dependency chain. The cache is automatically invalidated when any upstream value changes:
475
-
476
- ```html
477
- <wcs-state>
478
- <script type="module">
479
- export default {
480
- taxRate: 0.1,
481
- cart: {
482
- items: [
483
- { productId: "P001", quantity: 2, unitPrice: 500 },
484
- { productId: "P002", quantity: 1, unitPrice: 1200 }
485
- ]
486
- },
487
- // Per-item subtotal
488
- get "cart.items.*.subtotal"() {
489
- return this["cart.items.*.unitPrice"] * this["cart.items.*.quantity"];
490
- },
491
- // Aggregate: sum of all subtotals
492
- get "cart.totalPrice"() {
493
- return this.$getAll("cart.items.*.subtotal", []).reduce((sum, v) => sum + v, 0);
494
- },
495
- // Chained: tax derived from totalPrice
496
- get "cart.tax"() {
497
- return this["cart.totalPrice"] * this.taxRate;
498
- },
499
- // Chained: grand total
500
- get "cart.grandTotal"() {
501
- return this["cart.totalPrice"] + this["cart.tax"];
502
- }
503
- };
504
- </script>
505
- </wcs-state>
506
-
507
- <template data-wcs="for: cart.items">
508
- <div>
509
- <span data-wcs="textContent: .productId"></span>:
510
- <span data-wcs="textContent: .subtotal|locale"></span>
511
- </div>
512
- </template>
513
- <p>Total: <span data-wcs="textContent: cart.totalPrice|locale"></span></p>
514
- <p>Tax: <span data-wcs="textContent: cart.tax|locale"></span></p>
515
- <p>Grand Total: <span data-wcs="textContent: cart.grandTotal|locale"></span></p>
516
- ```
517
-
518
- Dependency chain: `cart.grandTotal` → `cart.tax` → `cart.totalPrice` → `cart.items.*.subtotal` → `cart.items.*.unitPrice` / `cart.items.*.quantity`. Changing any item's `unitPrice` or `quantity` automatically recomputes the entire chain.
519
-
520
- ### Nested Wildcard Getters
521
-
522
- Multiple wildcards are supported for nested array structures:
523
-
524
- ```html
525
- <wcs-state>
526
- <script type="module">
527
- export default {
528
- categories: [
529
- {
530
- name: "Fruits",
531
- items: [
532
- { name: "Apple", price: 150 },
533
- { name: "Banana", price: 100 }
534
- ]
535
- },
536
- {
537
- name: "Vegetables",
538
- items: [
539
- { name: "Carrot", price: 80 }
540
- ]
541
- }
542
- ],
543
- get "categories.*.items.*.label"() {
544
- return this["categories.*.name"] + " / " + this["categories.*.items.*.name"];
545
- }
546
- };
547
- </script>
548
- </wcs-state>
549
-
550
- <template data-wcs="for: categories">
551
- <h3 data-wcs="textContent: .name"></h3>
552
- <template data-wcs="for: .items">
553
- <div data-wcs="textContent: .label"></div>
554
- </template>
555
- </template>
556
- <!-- Output:
557
- Fruits
558
- Fruits / Apple
559
- Fruits / Banana
560
- Vegetables
561
- Vegetables / Carrot
562
- -->
563
- ```
564
-
565
- ### Flat Virtual Properties Across Any Depth
566
-
567
- 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.
568
-
569
- ```javascript
570
- export default {
571
- regions: [
572
- { name: "Kanto", prefectures: [
573
- { name: "Tokyo", cities: [
574
- { name: "Shibuya", population: 230000, area: 15.11 },
575
- { name: "Shinjuku", population: 346000, area: 18.22 }
576
- ]},
577
- { name: "Kanagawa", cities: [
578
- { name: "Yokohama", population: 3750000, area: 437.56 }
579
- ]}
580
- ]}
581
- ],
582
-
583
- // --- All flat, regardless of nesting depth ---
584
-
585
- // City level — virtual properties
586
- get "regions.*.prefectures.*.cities.*.density"() {
587
- return this["regions.*.prefectures.*.cities.*.population"]
588
- / this["regions.*.prefectures.*.cities.*.area"];
589
- },
590
- get "regions.*.prefectures.*.cities.*.label"() {
591
- return this["regions.*.prefectures.*.name"] + " "
592
- + this["regions.*.prefectures.*.cities.*.name"];
593
- },
594
-
595
- // Prefecture level — aggregate from cities
596
- get "regions.*.prefectures.*.totalPopulation"() {
597
- return this.$getAll("regions.*.prefectures.*.cities.*.population", [])
598
- .reduce((a, b) => a + b, 0);
599
- },
600
-
601
- // Region level — aggregate from prefectures
602
- get "regions.*.totalPopulation"() {
603
- return this.$getAll("regions.*.prefectures.*.totalPopulation", [])
604
- .reduce((a, b) => a + b, 0);
605
- },
606
-
607
- // Top level — aggregate from regions
608
- get totalPopulation() {
609
- return this.$getAll("regions.*.totalPopulation", [])
610
- .reduce((a, b) => a + b, 0);
611
- }
612
- };
613
- ```
614
-
615
- 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.
616
-
617
- ### Accessing Sub-Properties of Getter Results
618
-
619
- When a path getter returns an object, you can access its sub-properties via dot-path:
620
-
621
- ```javascript
622
- export default {
623
- products: [
624
- { id: "P001", name: "Widget", price: 500, stock: 10 },
625
- { id: "P002", name: "Gadget", price: 1200, stock: 3 }
626
- ],
627
- cart: {
628
- items: [
629
- { productId: "P001", quantity: 2 },
630
- { productId: "P002", quantity: 1 }
631
- ]
632
- },
633
- get productByProductId() {
634
- return new Map(this.products.map(p => [p.id, p]));
635
- },
636
- // Returns the full product object
637
- get "cart.items.*.product"() {
638
- return this.productByProductId.get(this["cart.items.*.productId"]);
639
- },
640
- // Access sub-property of the returned object
641
- get "cart.items.*.total"() {
642
- return this["cart.items.*.product.price"] * this["cart.items.*.quantity"];
643
- }
644
- };
645
- ```
646
-
647
- `this["cart.items.*.product.price"]` transparently chains through the object returned by the `cart.items.*.product` getter.
648
-
649
- ### Path Setters
650
-
651
- Custom setter logic can be defined with `set "path"()`:
652
-
653
- ```javascript
654
- export default {
655
- users: [
656
- { firstName: "Alice", lastName: "Smith" },
657
- { firstName: "Bob", lastName: "Jones" }
658
- ],
659
- get "users.*.fullName"() {
660
- return this["users.*.firstName"] + " " + this["users.*.lastName"];
661
- },
662
- set "users.*.fullName"(value) {
663
- const [first, ...rest] = value.split(" ");
664
- this["users.*.firstName"] = first;
665
- this["users.*.lastName"] = rest.join(" ");
666
- }
667
- };
668
- ```
669
-
670
- ```html
671
- <template data-wcs="for: users">
672
- <input type="text" data-wcs="value: .fullName">
673
- </template>
674
- ```
675
-
676
- Two-way binding works with path setters — editing the input calls the setter, which splits and writes back to `firstName` / `lastName`.
677
-
678
- ### Supported Path Getter Patterns
679
-
680
- | Pattern | Description | Example |
681
- |---|---|---|
682
- | `get prop()` | Top-level computed | `get total()` |
683
- | `get "a.b"()` | Nested computed (no wildcard) | `get "cart.totalPrice"()` |
684
- | `get "a.*.b"()` | Single wildcard | `get "users.*.fullName"()` |
685
- | `get "a.*.b.*.c"()` | Multiple wildcards | `get "categories.*.items.*.label"()` |
686
- | `set "a.*.b"(v)` | Wildcard setter | `set "users.*.fullName"(v)` |
687
-
688
- ### How It Works
689
-
690
- 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.
691
-
692
- 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.
693
-
694
- 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.
695
-
696
- 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.
697
-
698
- ### Loop Index Variables (`$1`, `$2`, ...)
699
-
700
- Inside getters and event handlers, `this.$1`, `this.$2`, etc. provide the current loop iteration index (0-based value, 1-based naming):
701
-
702
- ```javascript
703
- export default {
704
- users: ["Alice", "Bob", "Charlie"],
705
- get "users.*.rowLabel"() {
706
- return "#" + (this.$1 + 1) + ": " + this["users.*"];
707
- }
708
- };
709
- ```
710
-
711
- ```html
712
- <template data-wcs="for: users">
713
- <div data-wcs="textContent: .rowLabel"></div>
714
- </template>
715
- <!-- Output:
716
- #1: Alice
717
- #2: Bob
718
- #3: Charlie
719
- -->
720
- ```
721
-
722
- For nested loops, `$1` is the outer index and `$2` is the inner index.
723
-
724
- You can also display the loop index directly in templates:
725
-
726
- ```html
727
- <template data-wcs="for: items">
728
- <td>{{ $1|inc(1) }}</td> <!-- 1-based row number -->
729
- </template>
730
- ```
731
-
732
- ### Proxy APIs
733
-
734
- Inside state objects (getters / methods), the following APIs are available via `this`:
735
-
736
- | API | Description |
737
- |---|---|
738
- | `this.$getAll(path, indexes?)` | Get all values matching a wildcard path |
739
- | `this.$resolve(path, indexes, value?)` | Resolve a wildcard path with specific indexes |
740
- | `this.$postUpdate(path)` | Manually trigger update notification for a path |
741
- | `this.$trackDependency(path)` | Manually register a dependency for cache invalidation |
742
- | `this.$stateElement` | Access to the `IStateElement` instance |
743
- | `this.$1`, `this.$2`, ... | Current loop index (1-based naming, 0-based value) |
744
-
745
- #### `$getAll` — Aggregate Across Array Elements
746
-
747
- `$getAll` collects all values that match a wildcard path, returning them as an array. Essential for aggregation patterns:
748
-
749
- ```javascript
750
- export default {
751
- scores: [85, 92, 78, 95, 88],
752
- get average() {
753
- const all = this.$getAll("scores.*", []);
754
- return all.reduce((sum, v) => sum + v, 0) / all.length;
755
- },
756
- get max() {
757
- return Math.max(...this.$getAll("scores.*", []));
758
- }
759
- };
760
- ```
761
-
762
- #### `$resolve` — Access by Explicit Index
763
-
764
- `$resolve` reads or writes a value at a specific wildcard index:
765
-
766
- ```javascript
767
- export default {
768
- items: ["A", "B", "C"],
769
- swapFirstTwo() {
770
- const a = this.$resolve("items.*", [0]);
771
- const b = this.$resolve("items.*", [1]);
772
- this.$resolve("items.*", [0], b);
773
- this.$resolve("items.*", [1], a);
774
- }
775
- };
776
- ```
777
-
778
- ## Event Handling
779
-
780
- Bind event handlers with `on*` properties:
781
-
782
- ```html
783
- <button data-wcs="onclick: handleClick">Click me</button>
784
- <form data-wcs="onsubmit#prevent: handleSubmit">...</form>
785
- ```
786
-
787
- Handler methods receive the event and loop indexes:
788
-
789
- ```javascript
790
- export default {
791
- items: ["A", "B", "C"],
792
- handleClick(event) {
793
- console.log("clicked");
794
- },
795
- removeItem(event, index) {
796
- // index is the loop context ($1)
797
- this.items = this.items.toSpliced(index, 1);
798
- }
799
- };
800
- ```
801
-
802
- ```html
803
- <template data-wcs="for: items">
804
- <button data-wcs="onclick: removeItem">Delete</button>
805
- </template>
806
- ```
807
-
808
- ## Filters
809
-
810
- 40 built-in filters are available for both input (DOM → state) and output (state → DOM) directions.
811
-
812
- ### Comparison
813
-
814
- | Filter | Description | Example |
815
- |---|---|---|
816
- | `eq(value)` | Equal | `count\|eq(0)` → `true/false` |
817
- | `ne(value)` | Not equal | `count\|ne(0)` |
818
- | `not` | Boolean NOT | `isActive\|not` |
819
- | `lt(n)` | Less than | `count\|lt(10)` |
820
- | `le(n)` | Less than or equal | `count\|le(10)` |
821
- | `gt(n)` | Greater than | `count\|gt(0)` |
822
- | `ge(n)` | Greater than or equal | `count\|ge(0)` |
823
-
824
- ### Arithmetic
825
-
826
- | Filter | Description | Example |
827
- |---|---|---|
828
- | `inc(n)` | Add | `count\|inc(1)` |
829
- | `dec(n)` | Subtract | `count\|dec(1)` |
830
- | `mul(n)` | Multiply | `price\|mul(1.1)` |
831
- | `div(n)` | Divide | `total\|div(100)` |
832
- | `mod(n)` | Modulo | `index\|mod(2)` |
833
-
834
- ### Number Formatting
835
-
836
- | Filter | Description | Example |
837
- |---|---|---|
838
- | `fix(n)` | Fixed decimal places | `price\|fix(2)` → `"100.00"` |
839
- | `round(n?)` | Round | `value\|round(2)` |
840
- | `floor(n?)` | Floor | `value\|floor` |
841
- | `ceil(n?)` | Ceiling | `value\|ceil` |
842
- | `locale(loc?)` | Locale number format | `count\|locale` / `count\|locale(ja-JP)` |
843
- | `percent(n?)` | Percentage format | `ratio\|percent(1)` |
844
-
845
- ### String
846
-
847
- | Filter | Description | Example |
848
- |---|---|---|
849
- | `uc` | Upper case | `name\|uc` |
850
- | `lc` | Lower case | `name\|lc` |
851
- | `cap` | Capitalize | `name\|cap` |
852
- | `trim` | Trim whitespace | `text\|trim` |
853
- | `slice(n)` | Slice string | `text\|slice(5)` |
854
- | `substr(start, length)` | Substring | `text\|substr(0,10)` |
855
- | `pad(n, char?)` | Pad start | `id\|pad(5,0)` → `"00001"` |
856
- | `rep(n)` | Repeat | `text\|rep(3)` |
857
- | `rev` | Reverse | `text\|rev` |
858
-
859
- ### Type Conversion
860
-
861
- | Filter | Description | Example |
862
- |---|---|---|
863
- | `int` | Parse integer | `input\|int` |
864
- | `float` | Parse float | `input\|float` |
865
- | `boolean` | To boolean | `value\|boolean` |
866
- | `number` | To number | `value\|number` |
867
- | `string` | To string | `value\|string` |
868
- | `null` | To null | `value\|null` |
869
-
870
- ### Date / Time
871
-
872
- | Filter | Description | Example |
873
- |---|---|---|
874
- | `date(loc?)` | Date format | `timestamp\|date` / `timestamp\|date(ja-JP)` |
875
- | `time(loc?)` | Time format | `timestamp\|time` |
876
- | `datetime(loc?)` | Date + Time | `timestamp\|datetime(en-US)` |
877
- | `ymd(sep?)` | YYYY-MM-DD | `timestamp\|ymd` / `timestamp\|ymd(/)` |
878
-
879
- ### Boolean / Default
880
-
881
- | Filter | Description | Example |
882
- |---|---|---|
883
- | `truthy` | Truthy check | `value\|truthy` |
884
- | `falsy` | Falsy check | `value\|falsy` |
885
- | `defaults(v)` | Fallback value | `name\|defaults(Anonymous)` |
886
-
887
- ### Filter Chaining
888
-
889
- Filters can be chained with `|`:
890
-
891
- ```html
892
- <div data-wcs="textContent: price|mul(1.1)|round(2)|locale(ja-JP)"></div>
893
- ```
894
-
895
- ## Web Component Binding
896
-
897
- `@wcstack/state` supports bidirectional state binding with custom elements using Shadow DOM or Light DOM.
898
-
899
- 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:
900
-
901
- 1. The child references and updates the parent's state through its own state proxy — no props, no events, no awareness of the parent.
902
- 2. When the parent's state changes, the Proxy `set` trap automatically notifies any child bindings that reference the affected path.
903
- 3. Because the only coupling is the **path name**, both sides remain loosely coupled and independently testable.
904
- 4. The cost is path resolution (cached at O(1) after first access) plus change propagation through the dependency graph.
905
-
906
- This provides a lightweight approach to cross-component state management based on path resolution rather than component-level abstractions.
907
-
908
- ### Component Definition (Shadow DOM)
909
-
910
- ```javascript
911
- class MyComponent extends HTMLElement {
912
- state = { message: "" };
913
-
914
- constructor() {
915
- super();
916
- this.attachShadow({ mode: "open" });
917
- this.shadowRoot.innerHTML = `
918
- <wcs-state bind-component="state"></wcs-state>
919
- <div>{{ message }}</div>
920
- <input type="text" data-wcs="value: message" />
921
- `;
922
- }
923
- }
924
- customElements.define("my-component", MyComponent);
925
- ```
926
-
927
- ### Component Definition (Light DOM)
928
-
929
- 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.
930
-
931
- ```javascript
932
- class MyLightComponent extends HTMLElement {
933
- state = { message: "" };
934
-
935
- connectedCallback() {
936
- this.innerHTML = `
937
- <wcs-state bind-component="state" name="my-light"></wcs-state>
938
- <div data-wcs="text: message@my-light"></div>
939
- <input type="text" data-wcs="value: message@my-light" />
940
- `;
941
- }
942
- }
943
- customElements.define("my-light-component", MyLightComponent);
944
- ```
945
-
946
- - `name` attribute is **required** for Light DOM components (namespace is shared with the parent scope)
947
- - Bindings must explicitly reference the state name with `@my-light`
948
- - `<wcs-state>` must be a direct child of the component element
949
-
950
- ### Host Usage
951
-
952
- ```html
953
- <wcs-state>
954
- <script type="module">
955
- export default {
956
- user: { name: "Alice" }
957
- };
958
- </script>
959
- </wcs-state>
960
-
961
- <!-- Bind component's state.message to outer user.name -->
962
- <my-component data-wcs="state.message: user.name"></my-component>
963
- ```
964
-
965
- - `bind-component="state"` maps the component's `state` property to `<wcs-state>`
966
- - `data-wcs="state.message: user.name"` on the host element binds outer state paths to inner component state properties
967
- - Changes propagate bidirectionally between the component and the outer state
968
-
969
- ### Standalone Web Component Injection (`__e2e__/single-component`)
970
-
971
- Even when a component is independent from outer host state, you can inject reactive state with `bind-component`.
972
-
973
- ```javascript
974
- class MyComponent extends HTMLElement {
975
- state = Object.freeze({
976
- message: "Hello, World!"
977
- });
978
-
979
- constructor() {
980
- super();
981
- this.attachShadow({ mode: "open" });
982
- }
983
-
984
- connectedCallback() {
985
- this.shadowRoot.innerHTML = `
986
- <wcs-state bind-component="state"></wcs-state>
987
- <div>{{ message }}</div>
988
- `;
989
- }
990
-
991
- async $stateReadyCallback(stateProp) {
992
- console.log("state ready:", stateProp); // "state"
993
- }
994
- }
995
- customElements.define("my-component", MyComponent);
996
- ```
997
-
998
- - Initial component `state` can be defined with `Object.freeze(...)` (it is replaced with a writable reactive state after injection)
999
- - `bind-component="state"` exposes `this.state` as a state proxy powered by `@wcstack/state`
1000
- - Assignments like `this.state.message = "..."` immediately update `{{ message }}` inside Shadow DOM
1001
- - `async $stateReadyCallback(stateProp)` is called right after component state becomes ready for use (`stateProp` is the property name from `bind-component`)
1002
-
1003
- ### Constraints
1004
-
1005
- - `<wcs-state>` with `bind-component` must be a **direct child** of the component element (top-level)
1006
- - The parent element must be a **custom element** (tag name containing a hyphen)
1007
- - Light DOM components **require** a `name` attribute to avoid namespace conflicts with the parent scope
1008
- - Light DOM bindings must reference the state name explicitly (e.g., `@my-light`)
1009
-
1010
- ### Loop with Components
1011
-
1012
- ```html
1013
- <template data-wcs="for: users">
1014
- <my-component data-wcs="state.message: .name"></my-component>
1015
- </template>
1016
- ```
1017
-
1018
- ## Declarative Custom Components (DCC)
1019
-
1020
- 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.
1021
-
1022
- ### Basic Definition
1023
-
1024
- ```html
1025
- <!-- 1. Define the component (hidden by CSS) -->
1026
- <my-counter data-wc-definition>
1027
- <template shadowrootmode="open">
1028
- <p>{{ count }}</p>
1029
- <button data-wcs="onclick: increment">+1</button>
1030
- <wcs-state>
1031
- <script type="module">
1032
- export default {
1033
- count: 0,
1034
- increment() { this.count++; },
1035
- $bindables: ["count"]
1036
- };
1037
- </script>
1038
- </wcs-state>
1039
- </template>
1040
- </my-counter>
1041
-
1042
- <!-- 2. Use it — each instance gets its own state -->
1043
- <my-counter></my-counter>
1044
- <my-counter></my-counter>
1045
- ```
1046
-
1047
- When `<wcs-state>` detects it is inside a `data-wc-definition` host, it:
1048
-
1049
- 1. Loads the state object (from `<script type="module">` or `src="*.js"`)
1050
- 2. Generates a custom element class with getter/setter/method properties on the prototype
1051
- 3. Registers it via `customElements.define()`
1052
-
1053
- The definition element is hidden; each instance clones the template into its own Shadow DOM and initializes its own `<wcs-state>`.
1054
-
1055
- ### Recommended CSS
1056
-
1057
- ```css
1058
- :not(:defined) { display: none; }
1059
- [data-wc-definition] { display: none; }
1060
- ```
1061
-
1062
- ### `$bindables` and wc-bindable Protocol
1063
-
1064
- The `$bindables` array declares which state properties are exposed as component properties with change events, following the [wc-bindable protocol](https://github.com/nicenemo/nicenemo/blob/main/docs/wc-bindable-protocol.md):
1065
-
1066
- ```javascript
1067
- export default {
1068
- count: 0,
1069
- increment() { this.count++; },
1070
- $bindables: ["count"]
1071
- };
1072
- ```
1073
-
1074
- This generates:
1075
-
1076
- - `static wcBindable` on the class — protocol metadata for framework adapters
1077
- - Getter/setter on the prototype — reads/writes go through the reactive proxy
1078
- - `CustomEvent` dispatch — `my-counter:count-changed` fires on every mutation
1079
-
1080
- ### Binding to DCC Properties
1081
-
1082
- Other `<wcs-state>` instances can bind to DCC properties just like any Web Component:
1083
-
1084
- ```html
1085
- <my-counter data-wcs="count: parentCount"></my-counter>
1086
-
1087
- <wcs-state>
1088
- <script type="module">
1089
- export default { parentCount: 0 };
1090
- </script>
1091
- </wcs-state>
1092
- <div data-wcs="textContent: parentCount"></div>
1093
- ```
1094
-
1095
- ### Shadow Root Mode
1096
-
1097
- Both `open` and `closed` modes are supported:
1098
-
1099
- ```html
1100
- <my-component data-wc-definition>
1101
- <template shadowrootmode="closed">
1102
- <!-- closed shadow DOM -->
1103
- </template>
1104
- </my-component>
1105
- ```
1106
-
1107
- ### Internal Properties
1108
-
1109
- Properties prefixed with `$` are internal and not exposed on the component prototype:
1110
-
1111
- | Property | Purpose |
1112
- |----------|---------|
1113
- | `$bindables` | Declares observable properties |
1114
- | `$connectedCallback` | Lifecycle hook (runs on each instance) |
1115
- | `$disconnectedCallback` | Cleanup hook |
1116
- | `$updatedCallback` | Called after state mutations |
1117
-
1118
- ## SVG Support
1119
-
1120
- All bindings work inside `<svg>` elements. Use `attr.*` for SVG attributes:
1121
-
1122
- ```html
1123
- <svg width="200" height="100">
1124
- <template data-wcs="for: points">
1125
- <circle data-wcs="attr.cx: .x; attr.cy: .y; attr.fill: .color" r="5" />
1126
- </template>
1127
- </svg>
1128
- ```
1129
-
1130
- ## Lifecycle Hooks
1131
-
1132
- State objects can define `$connectedCallback`, `$disconnectedCallback`, and `$updatedCallback` for initialization, cleanup, and update lifecycle handling.
1133
-
1134
- ```html
1135
- <wcs-state>
1136
- <script type="module">
1137
- export default {
1138
- timer: null,
1139
- count: 0,
1140
-
1141
- // Called when <wcs-state> is connected to the DOM
1142
- async $connectedCallback() {
1143
- const res = await fetch("/api/initial-count");
1144
- this.count = await res.json();
1145
- this.timer = setInterval(() => { this.count++; }, 1000);
1146
- },
1147
-
1148
- // Called when <wcs-state> is disconnected from the DOM (sync only)
1149
- $disconnectedCallback() {
1150
- clearInterval(this.timer);
1151
- }
1152
- };
1153
- </script>
1154
- </wcs-state>
1155
- ```
1156
-
1157
- | Hook | Timing | Async |
1158
- |---|---|---|
1159
- | `$connectedCallback` | After state initialization on first connect; on every reconnect thereafter | Yes (awaited) |
1160
- | `$disconnectedCallback` | When the element is removed from the DOM | No (sync only) |
1161
- | `$updatedCallback(paths, indexesListByPath)` | After state updates are applied | Yes (not awaited) |
1162
-
1163
- 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.
1164
-
1165
- - `this` inside hooks is the state proxy with full read/write access
1166
- - `$connectedCallback` is called **every time** the element is connected (including re-insertion after removal), making it suitable for setup that should be re-established
1167
- - `$disconnectedCallback` is called synchronously — use it for cleanup such as clearing timers, removing event listeners, or releasing resources
1168
- - `$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
1169
- - In Web Components, define `async $stateReadyCallback(stateProp)` to receive a hook when the bound state becomes available via `bind-component`
1170
-
1171
- ## Configuration
1172
-
1173
- Pass a partial configuration object to `bootstrapState()`:
1174
-
1175
- ```javascript
1176
- import { bootstrapState } from '@wcstack/state';
1177
-
1178
- bootstrapState({
1179
- locale: 'ja-JP',
1180
- debug: true,
1181
- enableMustache: false,
1182
- tagNames: { state: 'my-state' },
1183
- });
1184
- ```
1185
-
1186
- All options with defaults:
1187
-
1188
- | Option | Default | Description |
1189
- |---|---|---|
1190
- | `bindAttributeName` | `'data-wcs'` | Binding attribute name |
1191
- | `tagNames.state` | `'wcs-state'` | State element tag name |
1192
- | `locale` | `'en'` | Default locale for filters |
1193
- | `debug` | `false` | Debug mode |
1194
- | `enableMustache` | `true` | Enable `{{ }}` syntax |
1195
-
1196
- ## TypeScript Support
1197
-
1198
- `defineState()` wraps your state object and provides type-safe `this` inside methods and getters — with zero runtime cost (identity function).
1199
-
1200
- ```typescript
1201
- import { defineState } from '@wcstack/state';
1202
-
1203
- export default defineState({
1204
- count: 0,
1205
- users: [] as { name: string; age: number }[],
1206
-
1207
- increment() {
1208
- this.count++; // ✅ number
1209
- this["users.*.name"]; // ✅ string (dot-path resolution)
1210
- this.$getAll("users.*.age", []); // ✅ API method
1211
- },
1212
-
1213
- get "users.*.ageCategory"() {
1214
- return this["users.*.age"] < 25 ? "Young" : "Adult";
1215
- }
1216
- });
1217
- ```
1218
-
1219
- Utility types `WcsPaths<T>` and `WcsPathValue<T, P>` are also exported for advanced use cases. See [docs/define-state.md](docs/define-state.md) for full documentation.
1220
-
1221
- ## API Reference
1222
-
1223
- ### `bootstrapState()`
1224
-
1225
- Initialize the state system. Registers `<wcs-state>` custom element and sets up DOM content loaded handler.
1226
-
1227
- ```javascript
1228
- import { bootstrapState } from '@wcstack/state';
1229
- bootstrapState();
1230
- ```
1231
-
1232
- ### `<wcs-state>` Element
1233
-
1234
- | Attribute | Description |
1235
- |---|---|
1236
- | `name` | State name (default: `"default"`) |
1237
- | `state` | ID of a `<script type="application/json">` element |
1238
- | `src` | URL to `.json` or `.js` file |
1239
- | `json` | Inline JSON string |
1240
- | `bind-component` | Property name for web component binding |
1241
-
1242
- ### IStateElement
1243
-
1244
- | Property / Method | Description |
1245
- |---|---|
1246
- | `name` | State name |
1247
- | `initializePromise` | Resolves when state is fully initialized |
1248
- | `listPaths` | Set of paths used in `for` loops |
1249
- | `getterPaths` | Set of paths defined as getters |
1250
- | `setterPaths` | Set of paths defined as setters |
1251
- | `createState(mutability, callback)` | Create a state proxy (`"readonly"` or `"writable"`) |
1252
- | `createStateAsync(mutability, callback)` | Async version of `createState` |
1253
- | `setInitialState(state)` | Set state programmatically (before initialization) |
1254
- | `bindProperty(prop, descriptor)` | Define a property on the raw state object |
1255
- | `nextVersion()` | Increment and return version number |
1256
-
1257
- ## Architecture
1258
-
1259
- ```
1260
- bootstrapState()
1261
- └── registerComponents() // Register <wcs-state> custom element
1262
-
1263
- <wcs-state> connectedCallback
1264
- ├── _initializeBindWebComponent() // bind-component: get state from parent component
1265
- ├── _initialize() // Load state (state attr / src / json / script / API)
1266
- │ └── setStateElementByName() // Register to WeakMap<Node, Map<name, element>>
1267
- │ └── (first registration per rootNode)
1268
- │ └── queueMicrotask → buildBindings()
1269
- ├── _callStateConnectedCallback() // Call $connectedCallback if defined
1270
-
1271
- buildBindings(root)
1272
- ├── waitForStateInitialize() // Wait for all <wcs-state> initializePromise
1273
- ├── convertMustacheToComments() // {{ }} → comment nodes
1274
- ├── collectStructuralFragments() // Collect for/if templates
1275
- └── initializeBindings() // Walk DOM, parse data-wcs, set up bindings
1276
- ```
1277
-
1278
- ### Reactivity Flow
1279
-
1280
- 1. State changes via Proxy `set` trap → `setByAddress()`
1281
- 2. Address resolved updater enqueues absolute address
1282
- 3. Dependency walker invalidates (dirties) downstream caches
1283
- 4. Updater applies changes to bound DOM nodes via `applyChangeFromBindings()`
1284
-
1285
- ### State Address System
1286
-
1287
- Paths like `users.*.name` are decomposed into:
1288
-
1289
- - **PathInfo** — static path metadata (segments, wildcard count, parent path)
1290
- - **ListIndex** — runtime loop index chain
1291
- - **StateAddress** — combination of PathInfo + ListIndex
1292
- - **AbsoluteStateAddress** — state name + StateAddress (for cross-state references)
1293
-
1294
- ## Server-Side Rendering
1295
-
1296
- `@wcstack/state` supports SSR via the companion [`@wcstack/server`](../server/) package. The same templates you write for the client render on the server — no changes needed.
1297
-
1298
- ### Quick Setup
1299
-
1300
- 1. Add `enable-ssr` to your `<wcs-state>` element:
1301
-
1302
- ```html
1303
- <wcs-state enable-ssr>
1304
- <script type="module">
1305
- export default {
1306
- items: [],
1307
- async $connectedCallback() {
1308
- const res = await fetch("/api/items");
1309
- this.items = await res.json();
1310
- }
1311
- };
1312
- </script>
1313
- </wcs-state>
1314
- <template data-wcs="for: items">
1315
- <div data-wcs="textContent: items.*.name"></div>
1316
- </template>
1317
- ```
1318
-
1319
- 2. Render on the server:
1320
-
1321
- ```javascript
1322
- import { renderToString } from "@wcstack/server";
1323
-
1324
- const html = await renderToString(template, {
1325
- baseUrl: "http://localhost:3000"
1326
- });
1327
- ```
1328
-
1329
- 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.
1330
-
1331
- ### How It Works
1332
-
1333
- | Phase | What happens |
1334
- |-------|-------------|
1335
- | **Server** | `renderToString()` runs your template in happy-dom, executes `$connectedCallback` (including `fetch()`), applies all bindings, and outputs rendered HTML with a `<wcs-ssr>` element containing hydration data |
1336
- | **Client** | `<wcs-state enable-ssr>` loads state from `<wcs-ssr>` JSON, skips `$connectedCallback`, and `hydrateBindings()` wires up reactivity on the existing DOM |
1337
- | **Fallback** | If server/client versions mismatch, the SSR DOM is cleaned up and `buildBindings()` runs a full client-side render |
1338
-
1339
- ### What `enable-ssr` Does
1340
-
1341
- | Context | Behavior |
1342
- |---------|----------|
1343
- | **Server** (`renderToString`) | Generates `<wcs-ssr>` with state JSON, template fragments, and property data |
1344
- | **Client** (hydration) | Reads `<wcs-ssr>`, restores state, skips `$connectedCallback`, hydrates bindings on existing DOM |
1345
-
1346
- See [`@wcstack/server` README](../server/README.md) for full API documentation.
1347
-
1348
- ## License
1349
-
1350
- MIT
1
+ # @wcstack/state
2
+
3
+ **This is not another convenient frontend framework. It is a different lineage that rearranges the premises of frontend development.**
4
+
5
+ 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.
6
+
7
+ 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.
8
+
9
+ ## What Does Not Exist Here
10
+
11
+ The following are not missing features. **They do not exist by design.**
12
+
13
+ - APIs for pulling variables out of state into components
14
+ - Per-element binding objects that mediate state access
15
+ - hooks
16
+ - selectors
17
+ - glue code that imports reactive primitives into component code
18
+
19
+ None of these exist by design.
20
+
21
+ 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.
22
+
23
+ ## Do Not Compare This to Existing Frameworks
24
+
25
+ This is not solving the same problem as React / Vue / Solid with a different syntax. **The premises are different.**
26
+
27
+ | What mainstream frameworks assume | What `@wcstack/state` assumes |
28
+ |---|---|
29
+ | Components are the coupling point between UI and state | Path strings are the coupling point between UI and state |
30
+ | JavaScript is the center of rendering | HTML and the DOM are the center |
31
+ | State is pulled into components | Paths are declared and the DOM connects to state |
32
+ | hooks / selectors / signals express subscriptions | Attributes and paths express bindings |
33
+ | The whole app runs inside a framework execution model | A thin reactive layer is added on top of web standards |
34
+
35
+ 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.
36
+
37
+ ## First Principle: Path as the Universal Contract
38
+
39
+ 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.
40
+
41
+ `@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:
42
+
43
+ | Layer | What it knows | What it doesn't know |
44
+ |-------|---------------|----------------------|
45
+ | **State** (`<wcs-state>`) | Data structure and business logic | Which DOM nodes are bound |
46
+ | **UI** (`data-wcs`) | Path strings and display intent | How state is stored or computed |
47
+ | **Components** (`@name`) | The path they need from a named state | The other component's internals |
48
+
49
+ Three levels of path contracts keep everything loosely coupled:
50
+
51
+ 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.
52
+
53
+ 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.
54
+
55
+ 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.
56
+
57
+ ### Why This Matters
58
+
59
+ This is complete separation of UI and state with **no JavaScript intermediary**. You can:
60
+
61
+ - Redesign the entire UI without touching state logic
62
+ - Refactor state structure and only update path strings
63
+ - Read the HTML alone and understand every data dependency
64
+
65
+ 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.
66
+
67
+ Every feature below is a consequence of this principle. The principle comes first; the features follow from it.
68
+
69
+ ## 4 Steps to Reactive HTML
70
+
71
+ ```html
72
+ <!-- 1. Load the CDN -->
73
+ <script type="module" src="https://esm.run/@wcstack/state/auto"></script>
74
+
75
+ <!-- 2. Write a <wcs-state> tag -->
76
+ <wcs-state>
77
+ <!-- 3. Define your state object -->
78
+ <script type="module">
79
+ export default {
80
+ message: "Hello, World!"
81
+ };
82
+ </script>
83
+ </wcs-state>
84
+
85
+ <!-- 4. Bind with data-wcs attributes -->
86
+ <div data-wcs="textContent: message"></div>
87
+ ```
88
+
89
+ That's it. No build, no bootstrap code, no framework.
90
+
91
+ ## Features Derived from This Principle
92
+
93
+ - **Declarative data binding** — `data-wcs` attribute for property / text / event / structural binding
94
+ - **Reactive Proxy** — ES Proxy-based automatic DOM updates with dependency tracking
95
+ - **Structural directives** `for`, `if` / `elseif` / `else` via `<template>` elements
96
+ - **Built-in filters** — 40 filters for formatting, comparison, arithmetic, date, and more
97
+ - **Two-way binding** — automatic for `<input>`, `<select>`, `<textarea>`
98
+ - **Web Component binding** — bidirectional state binding with Shadow DOM components
99
+ - **Command tokens** — invoke methods on wc-bindable custom elements from state via a pub/sub channel (`command.<method>: tokenName`)
100
+ - **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
101
+ - **Mustache syntax** — `{{ path|filter }}` in text nodes
102
+ - **Multiple state sources** — JSON, JS module, inline script, API, attribute
103
+ - **SVG support** — full binding support inside `<svg>` elements
104
+ - **Lifecycle hooks** — `$connectedCallback` / `$disconnectedCallback` / `$updatedCallback`, plus `$stateReadyCallback` for Web Components
105
+ - **TypeScript support** — `defineState()` for typed state definitions with dot-path autocompletion ([details](docs/define-state.md))
106
+ - **Server-Side Rendering** — `enable-ssr` attribute + `@wcstack/server` for full SSR with automatic hydration
107
+ - **Zero dependencies** no runtime dependencies
108
+
109
+ ## Installation
110
+
111
+ ### CDN (recommended)
112
+
113
+ ```html
114
+ <!-- Auto-initialization this is all you need -->
115
+ <script type="module" src="https://esm.run/@wcstack/state/auto"></script>
116
+ ```
117
+
118
+ ### CDN (manual initialization)
119
+
120
+ ```html
121
+ <script type="module">
122
+ import { bootstrapState } from 'https://esm.run/@wcstack/state';
123
+ bootstrapState();
124
+ </script>
125
+ ```
126
+
127
+ ## Basic Usage
128
+
129
+ ```html
130
+ <wcs-state>
131
+ <script type="module">
132
+ export default {
133
+ count: 0,
134
+ user: { id: 1, name: "Alice" },
135
+ users: [
136
+ { id: 1, name: "Alice" },
137
+ { id: 2, name: "Bob" },
138
+ { id: 3, name: "Charlie" }
139
+ ],
140
+ countUp() { this.count += 1; },
141
+ clearCount() { this.count = 0; },
142
+ get "users.*.displayName"() {
143
+ return this["users.*.name"] + " (ID: " + this["users.*.id"] + ")";
144
+ }
145
+ };
146
+ </script>
147
+ </wcs-state>
148
+
149
+ <!-- Text binding -->
150
+ <div data-wcs="textContent: count"></div>
151
+ {{ count }}
152
+
153
+ <!-- Two-way input binding -->
154
+ <input type="text" data-wcs="value: user.name">
155
+
156
+ <!-- Event binding -->
157
+ <button data-wcs="onclick: countUp">Increment</button>
158
+
159
+ <!-- Conditional class -->
160
+ <div data-wcs="textContent: count; class.over: count|gt(10)"></div>
161
+
162
+ <!-- Loop -->
163
+ <template data-wcs="for: users">
164
+ <div>
165
+ <span data-wcs="textContent: .id"></span>:
166
+ <span data-wcs="textContent: .displayName"></span>
167
+ </div>
168
+ </template>
169
+
170
+ <!-- Conditional rendering -->
171
+ <template data-wcs="if: count|gt(0)">
172
+ <p>The count is positive.</p>
173
+ </template>
174
+ <template data-wcs="elseif: count|lt(0)">
175
+ <p>The count is negative.</p>
176
+ </template>
177
+ <template data-wcs="else:">
178
+ <p>The count is zero.</p>
179
+ </template>
180
+ ```
181
+
182
+ ## State Initialization
183
+
184
+ `<wcs-state>` supports multiple ways to load initial state:
185
+
186
+ ```html
187
+ <!-- 1. Reference a <script type="application/json"> by id -->
188
+ <script type="application/json" id="state">
189
+ { "count": 0 }
190
+ </script>
191
+ <wcs-state state="state"></wcs-state>
192
+
193
+ <!-- 2. Inline JSON attribute -->
194
+ <wcs-state json='{ "count": 0 }'></wcs-state>
195
+
196
+ <!-- 3. External JSON file -->
197
+ <wcs-state src="./data.json"></wcs-state>
198
+
199
+ <!-- 4. External JS module (export default { ... }) -->
200
+ <wcs-state src="./state.js"></wcs-state>
201
+
202
+ <!-- 5. Inline script module -->
203
+ <wcs-state>
204
+ <script type="module">
205
+ export default { count: 0 };
206
+ </script>
207
+ </wcs-state>
208
+
209
+ <!-- 6. Programmatic API -->
210
+ <script>
211
+ const el = document.createElement('wcs-state');
212
+ el.setInitialState({ count: 0 });
213
+ document.body.appendChild(el);
214
+ </script>
215
+ ```
216
+
217
+ Resolution order: `state` → `src` (.json / .js) `json` inner `<script>` → wait for `setInitialState()`.
218
+
219
+ ### Named State
220
+
221
+ Multiple state elements can coexist with the `name` attribute. Bindings reference them with `@name`:
222
+
223
+ ```html
224
+ <wcs-state name="cart">...</wcs-state>
225
+ <wcs-state name="user">...</wcs-state>
226
+
227
+ <div data-wcs="textContent: total@cart"></div>
228
+ <div data-wcs="textContent: name@user"></div>
229
+ ```
230
+
231
+ Default name is `"default"` (no `@` needed).
232
+
233
+ ## Updating State
234
+
235
+ In `@wcstack/state`, every piece of state has a **path** — like `count`, `user.name`, or `items`. To update state reactively, **assign to the path**:
236
+
237
+ ```javascript
238
+ this.count = 10; // path "count"
239
+ this["user.name"] = "Bob"; // path "user.name"
240
+ ```
241
+
242
+ That's the one rule: **assign to the path, and the DOM updates automatically.**
243
+
244
+ ### Why `this.user.name = "Bob"` Doesn't Work
245
+
246
+ This is not just a limitation. It is where the contract boundary becomes visible.
247
+
248
+ `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:
249
+
250
+ ```javascript
251
+ // ✅ Path assignment — change detected
252
+ this["user.name"] = "Bob";
253
+
254
+ // ❌ Not a path assignment — change NOT detected
255
+ this.user.name = "Bob";
256
+ ```
257
+
258
+ 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.
259
+
260
+ ### Arrays
261
+
262
+ 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:
263
+
264
+ ```javascript
265
+ // New array assigned to path change detected
266
+ this.items = this.items.concat({ id: 4, text: "New" });
267
+ this.items = this.items.toSpliced(index, 1);
268
+ this.items = this.items.filter(item => !item.done);
269
+ this.items = this.items.toSorted((a, b) => a.id - b.id);
270
+ this.items = this.items.toReversed();
271
+ this.items = this.items.with(index, newValue);
272
+
273
+ // ❌ In-place mutation — no path assignment, change NOT detected
274
+ this.items.push({ id: 4, text: "New" });
275
+ this.items.splice(index, 1);
276
+ this.items.sort((a, b) => a.id - b.id);
277
+ ```
278
+
279
+ ## Binding Syntax
280
+
281
+ ### `data-wcs` Attribute
282
+
283
+ ```
284
+ property[#modifier]: path[@state][|filter[|filter(args)...]]
285
+ ```
286
+
287
+ Multiple bindings separated by `;`:
288
+
289
+ ```html
290
+ <div data-wcs="textContent: count; class.over: count|gt(10)"></div>
291
+ ```
292
+
293
+ | Part | Description | Example |
294
+ |---|---|---|
295
+ | `property` | DOM property to bind | `value`, `textContent`, `checked` |
296
+ | `#modifier` | Binding modifier | `#ro`, `#prevent`, `#stop`, `#onchange` |
297
+ | `path` | State property path | `count`, `user.name`, `users.*.name` |
298
+ | `@state` | Named state reference | `@cart`, `@user` |
299
+ | `\|filter` | Transform filter chain | `\|gt(0)`, `\|round\|locale` |
300
+
301
+ ### Property Types
302
+
303
+ | Property | Description |
304
+ |---|---|
305
+ | `value` | Element value (two-way for inputs) |
306
+ | `checked` | Checkbox / radio checked state (two-way) |
307
+ | `textContent` | Text content |
308
+ | `text` | Alias for textContent |
309
+ | `html` | innerHTML |
310
+ | `class.NAME` | Toggle a CSS class |
311
+ | `style.PROP` | Set a CSS style property |
312
+ | `attr.NAME` | Set an attribute (supports SVG namespace) |
313
+ | `radio` | Radio button group binding (two-way) |
314
+ | `checkbox` | Checkbox group binding to array (two-way) |
315
+ | `onclick`, `on*` | Event handler binding |
316
+
317
+ ### Modifiers
318
+
319
+ | Modifier | Description |
320
+ |---|---|
321
+ | `#ro` | Read-only — disables two-way binding |
322
+ | `#prevent` | Calls `event.preventDefault()` on event handlers |
323
+ | `#stop` | Calls `event.stopPropagation()` on event handlers |
324
+ | `#onchange` | Uses `change` event instead of `input` for two-way binding |
325
+
326
+ ### Two-Way Binding
327
+
328
+ Automatically enabled for:
329
+
330
+ | Element | Property | Event |
331
+ |---|---|---|
332
+ | `<input type="checkbox/radio">` | `checked` | `input` |
333
+ | `<input>` (other types) | `value`, `valueAsNumber`, `valueAsDate` | `input` |
334
+ | `<select>` | `value` | `change` |
335
+ | `<textarea>` | `value` | `input` |
336
+
337
+ `<input type="button">` is excluded. Use `#ro` to disable, `#onchange` to change the event.
338
+
339
+ ### Radio Binding
340
+
341
+ Bind a radio button group to a single state value with `radio`:
342
+
343
+ ```html
344
+ <input type="radio" value="red" data-wcs="radio: selectedColor">
345
+ <input type="radio" value="blue" data-wcs="radio: selectedColor">
346
+ ```
347
+
348
+ 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.
349
+
350
+ Inside a `for` loop:
351
+
352
+ ```html
353
+ <template data-wcs="for: branches">
354
+ <label>
355
+ <input type="radio" data-wcs="value: .; radio: currentBranch">
356
+ {{ . }}
357
+ </label>
358
+ </template>
359
+ ```
360
+
361
+ ### Checkbox Binding
362
+
363
+ Bind a checkbox group to a state array with `checkbox`:
364
+
365
+ ```html
366
+ <input type="checkbox" value="apple" data-wcs="checkbox: selectedFruits">
367
+ <input type="checkbox" value="banana" data-wcs="checkbox: selectedFruits">
368
+ <input type="checkbox" value="orange" data-wcs="checkbox: selectedFruits">
369
+ ```
370
+
371
+ 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.
372
+
373
+ ### Mustache Syntax
374
+
375
+ When `enableMustache` is `true` (default), `{{ expression }}` in text nodes is supported:
376
+
377
+ ```html
378
+ <p>Hello, {{ user.name }}!</p>
379
+ <p>Count: {{ count|locale }}</p>
380
+ ```
381
+
382
+ Internally converted to comment-based bindings (`<!--@@:expression-->`).
383
+
384
+ ## Structural Directives
385
+
386
+ Structural directives use `<template>` elements:
387
+
388
+ ### Loop (`for`)
389
+
390
+ ```html
391
+ <template data-wcs="for: users">
392
+ <div>
393
+ <!-- Full path -->
394
+ <span data-wcs="textContent: users.*.name"></span>
395
+ <!-- Shorthand (relative to loop context) -->
396
+ <span data-wcs="textContent: .name"></span>
397
+ </div>
398
+ </template>
399
+ ```
400
+
401
+ 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.
402
+
403
+ #### Dot Shorthand
404
+
405
+ Inside a `for` loop, paths starting with `.` are expanded relative to the loop's array path:
406
+
407
+ | Shorthand | Expanded to | Description |
408
+ |---|---|---|
409
+ | `.name` | `users.*.name` | Property of the current element |
410
+ | `.` | `users.*` | The current element itself |
411
+ | `.name\|uc` | `users.*.name\|uc` | Filters are preserved |
412
+ | `.name@state` | `users.*.name@state` | State name is preserved |
413
+
414
+ For primitive arrays, `.` refers to the element value directly:
415
+
416
+ ```html
417
+ <template data-wcs="for: branches">
418
+ <label>
419
+ <input type="radio" data-wcs="value: .; radio: currentBranch">
420
+ {{ . }}
421
+ </label>
422
+ </template>
423
+ ```
424
+
425
+ Nested loops are supported with multi-level wildcards. The `.` shorthand in nested `for` directives also expands relative to the parent loop path:
426
+
427
+ ```html
428
+ <template data-wcs="for: regions">
429
+ <!-- .states → regions.*.states -->
430
+ <template data-wcs="for: .states">
431
+ <!-- .name → regions.*.states.*.name -->
432
+ <span data-wcs="textContent: .name"></span>
433
+ </template>
434
+ </template>
435
+ ```
436
+
437
+ ### Conditional (`if` / `elseif` / `else`)
438
+
439
+ ```html
440
+ <template data-wcs="if: count|gt(0)">
441
+ <p>Positive</p>
442
+ </template>
443
+ <template data-wcs="elseif: count|lt(0)">
444
+ <p>Negative</p>
445
+ </template>
446
+ <template data-wcs="else:">
447
+ <p>Zero</p>
448
+ </template>
449
+ ```
450
+
451
+ Conditions can be chained. `elseif` automatically inverts the previous condition.
452
+
453
+ ## Path Getters (Computed Properties)
454
+
455
+ **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.
456
+
457
+ ### Basic Path Getter
458
+
459
+ ```html
460
+ <wcs-state>
461
+ <script type="module">
462
+ export default {
463
+ users: [
464
+ { id: 1, firstName: "Alice", lastName: "Smith" },
465
+ { id: 2, firstName: "Bob", lastName: "Jones" }
466
+ ],
467
+ // Path getter runs per-element inside a loop
468
+ get "users.*.fullName"() {
469
+ return this["users.*.firstName"] + " " + this["users.*.lastName"];
470
+ },
471
+ get "users.*.displayName"() {
472
+ return this["users.*.fullName"] + " (ID: " + this["users.*.id"] + ")";
473
+ }
474
+ };
475
+ </script>
476
+ </wcs-state>
477
+
478
+ <template data-wcs="for: users">
479
+ <div data-wcs="textContent: .displayName"></div>
480
+ </template>
481
+ <!-- Output:
482
+ Alice Smith (ID: 1)
483
+ Bob Jones (ID: 2)
484
+ -->
485
+ ```
486
+
487
+ Inside a path getter, `this["users.*.firstName"]` automatically resolves to the current loop element — no manual indexing needed.
488
+
489
+ ### Top-Level Computed Properties
490
+
491
+ Getters without wildcards work as standard computed properties:
492
+
493
+ ```javascript
494
+ export default {
495
+ price: 100,
496
+ tax: 0.1,
497
+ get total() {
498
+ return this.price * (1 + this.tax);
499
+ }
500
+ };
501
+ ```
502
+
503
+ ### Getter Chaining
504
+
505
+ Path getters can reference other path getters, forming a dependency chain. The cache is automatically invalidated when any upstream value changes:
506
+
507
+ ```html
508
+ <wcs-state>
509
+ <script type="module">
510
+ export default {
511
+ taxRate: 0.1,
512
+ cart: {
513
+ items: [
514
+ { productId: "P001", quantity: 2, unitPrice: 500 },
515
+ { productId: "P002", quantity: 1, unitPrice: 1200 }
516
+ ]
517
+ },
518
+ // Per-item subtotal
519
+ get "cart.items.*.subtotal"() {
520
+ return this["cart.items.*.unitPrice"] * this["cart.items.*.quantity"];
521
+ },
522
+ // Aggregate: sum of all subtotals
523
+ get "cart.totalPrice"() {
524
+ return this.$getAll("cart.items.*.subtotal", []).reduce((sum, v) => sum + v, 0);
525
+ },
526
+ // Chained: tax derived from totalPrice
527
+ get "cart.tax"() {
528
+ return this["cart.totalPrice"] * this.taxRate;
529
+ },
530
+ // Chained: grand total
531
+ get "cart.grandTotal"() {
532
+ return this["cart.totalPrice"] + this["cart.tax"];
533
+ }
534
+ };
535
+ </script>
536
+ </wcs-state>
537
+
538
+ <template data-wcs="for: cart.items">
539
+ <div>
540
+ <span data-wcs="textContent: .productId"></span>:
541
+ <span data-wcs="textContent: .subtotal|locale"></span>
542
+ </div>
543
+ </template>
544
+ <p>Total: <span data-wcs="textContent: cart.totalPrice|locale"></span></p>
545
+ <p>Tax: <span data-wcs="textContent: cart.tax|locale"></span></p>
546
+ <p>Grand Total: <span data-wcs="textContent: cart.grandTotal|locale"></span></p>
547
+ ```
548
+
549
+ Dependency chain: `cart.grandTotal` → `cart.tax` → `cart.totalPrice` → `cart.items.*.subtotal` → `cart.items.*.unitPrice` / `cart.items.*.quantity`. Changing any item's `unitPrice` or `quantity` automatically recomputes the entire chain.
550
+
551
+ ### Nested Wildcard Getters
552
+
553
+ Multiple wildcards are supported for nested array structures:
554
+
555
+ ```html
556
+ <wcs-state>
557
+ <script type="module">
558
+ export default {
559
+ categories: [
560
+ {
561
+ name: "Fruits",
562
+ items: [
563
+ { name: "Apple", price: 150 },
564
+ { name: "Banana", price: 100 }
565
+ ]
566
+ },
567
+ {
568
+ name: "Vegetables",
569
+ items: [
570
+ { name: "Carrot", price: 80 }
571
+ ]
572
+ }
573
+ ],
574
+ get "categories.*.items.*.label"() {
575
+ return this["categories.*.name"] + " / " + this["categories.*.items.*.name"];
576
+ }
577
+ };
578
+ </script>
579
+ </wcs-state>
580
+
581
+ <template data-wcs="for: categories">
582
+ <h3 data-wcs="textContent: .name"></h3>
583
+ <template data-wcs="for: .items">
584
+ <div data-wcs="textContent: .label"></div>
585
+ </template>
586
+ </template>
587
+ <!-- Output:
588
+ Fruits
589
+ Fruits / Apple
590
+ Fruits / Banana
591
+ Vegetables
592
+ Vegetables / Carrot
593
+ -->
594
+ ```
595
+
596
+ ### Flat Virtual Properties Across Any Depth
597
+
598
+ 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.
599
+
600
+ ```javascript
601
+ export default {
602
+ regions: [
603
+ { name: "Kanto", prefectures: [
604
+ { name: "Tokyo", cities: [
605
+ { name: "Shibuya", population: 230000, area: 15.11 },
606
+ { name: "Shinjuku", population: 346000, area: 18.22 }
607
+ ]},
608
+ { name: "Kanagawa", cities: [
609
+ { name: "Yokohama", population: 3750000, area: 437.56 }
610
+ ]}
611
+ ]}
612
+ ],
613
+
614
+ // --- All flat, regardless of nesting depth ---
615
+
616
+ // City level — virtual properties
617
+ get "regions.*.prefectures.*.cities.*.density"() {
618
+ return this["regions.*.prefectures.*.cities.*.population"]
619
+ / this["regions.*.prefectures.*.cities.*.area"];
620
+ },
621
+ get "regions.*.prefectures.*.cities.*.label"() {
622
+ return this["regions.*.prefectures.*.name"] + " "
623
+ + this["regions.*.prefectures.*.cities.*.name"];
624
+ },
625
+
626
+ // Prefecture level — aggregate from cities
627
+ get "regions.*.prefectures.*.totalPopulation"() {
628
+ return this.$getAll("regions.*.prefectures.*.cities.*.population", [])
629
+ .reduce((a, b) => a + b, 0);
630
+ },
631
+
632
+ // Region level — aggregate from prefectures
633
+ get "regions.*.totalPopulation"() {
634
+ return this.$getAll("regions.*.prefectures.*.totalPopulation", [])
635
+ .reduce((a, b) => a + b, 0);
636
+ },
637
+
638
+ // Top level — aggregate from regions
639
+ get totalPopulation() {
640
+ return this.$getAll("regions.*.totalPopulation", [])
641
+ .reduce((a, b) => a + b, 0);
642
+ }
643
+ };
644
+ ```
645
+
646
+ 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.
647
+
648
+ ### Accessing Sub-Properties of Getter Results
649
+
650
+ When a path getter returns an object, you can access its sub-properties via dot-path:
651
+
652
+ ```javascript
653
+ export default {
654
+ products: [
655
+ { id: "P001", name: "Widget", price: 500, stock: 10 },
656
+ { id: "P002", name: "Gadget", price: 1200, stock: 3 }
657
+ ],
658
+ cart: {
659
+ items: [
660
+ { productId: "P001", quantity: 2 },
661
+ { productId: "P002", quantity: 1 }
662
+ ]
663
+ },
664
+ get productByProductId() {
665
+ return new Map(this.products.map(p => [p.id, p]));
666
+ },
667
+ // Returns the full product object
668
+ get "cart.items.*.product"() {
669
+ return this.productByProductId.get(this["cart.items.*.productId"]);
670
+ },
671
+ // Access sub-property of the returned object
672
+ get "cart.items.*.total"() {
673
+ return this["cart.items.*.product.price"] * this["cart.items.*.quantity"];
674
+ }
675
+ };
676
+ ```
677
+
678
+ `this["cart.items.*.product.price"]` transparently chains through the object returned by the `cart.items.*.product` getter.
679
+
680
+ ### Path Setters
681
+
682
+ Custom setter logic can be defined with `set "path"()`:
683
+
684
+ ```javascript
685
+ export default {
686
+ users: [
687
+ { firstName: "Alice", lastName: "Smith" },
688
+ { firstName: "Bob", lastName: "Jones" }
689
+ ],
690
+ get "users.*.fullName"() {
691
+ return this["users.*.firstName"] + " " + this["users.*.lastName"];
692
+ },
693
+ set "users.*.fullName"(value) {
694
+ const [first, ...rest] = value.split(" ");
695
+ this["users.*.firstName"] = first;
696
+ this["users.*.lastName"] = rest.join(" ");
697
+ }
698
+ };
699
+ ```
700
+
701
+ ```html
702
+ <template data-wcs="for: users">
703
+ <input type="text" data-wcs="value: .fullName">
704
+ </template>
705
+ ```
706
+
707
+ Two-way binding works with path setters — editing the input calls the setter, which splits and writes back to `firstName` / `lastName`.
708
+
709
+ ### Supported Path Getter Patterns
710
+
711
+ | Pattern | Description | Example |
712
+ |---|---|---|
713
+ | `get prop()` | Top-level computed | `get total()` |
714
+ | `get "a.b"()` | Nested computed (no wildcard) | `get "cart.totalPrice"()` |
715
+ | `get "a.*.b"()` | Single wildcard | `get "users.*.fullName"()` |
716
+ | `get "a.*.b.*.c"()` | Multiple wildcards | `get "categories.*.items.*.label"()` |
717
+ | `set "a.*.b"(v)` | Wildcard setter | `set "users.*.fullName"(v)` |
718
+
719
+ ### How It Works
720
+
721
+ 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.
722
+
723
+ 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.
724
+
725
+ 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.
726
+
727
+ 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.
728
+
729
+ ### Loop Index Variables (`$1`, `$2`, ...)
730
+
731
+ Inside getters and event handlers, `this.$1`, `this.$2`, etc. provide the current loop iteration index (0-based value, 1-based naming):
732
+
733
+ ```javascript
734
+ export default {
735
+ users: ["Alice", "Bob", "Charlie"],
736
+ get "users.*.rowLabel"() {
737
+ return "#" + (this.$1 + 1) + ": " + this["users.*"];
738
+ }
739
+ };
740
+ ```
741
+
742
+ ```html
743
+ <template data-wcs="for: users">
744
+ <div data-wcs="textContent: .rowLabel"></div>
745
+ </template>
746
+ <!-- Output:
747
+ #1: Alice
748
+ #2: Bob
749
+ #3: Charlie
750
+ -->
751
+ ```
752
+
753
+ For nested loops, `$1` is the outer index and `$2` is the inner index.
754
+
755
+ You can also display the loop index directly in templates:
756
+
757
+ ```html
758
+ <template data-wcs="for: items">
759
+ <td>{{ $1|inc(1) }}</td> <!-- 1-based row number -->
760
+ </template>
761
+ ```
762
+
763
+ ### Proxy APIs
764
+
765
+ Inside state objects (getters / methods), the following APIs are available via `this`:
766
+
767
+ | API | Description |
768
+ |---|---|
769
+ | `this.$getAll(path, indexes?)` | Get all values matching a wildcard path |
770
+ | `this.$resolve(path, indexes, value?)` | Resolve a wildcard path with specific indexes |
771
+ | `this.$postUpdate(path)` | Manually trigger update notification for a path |
772
+ | `this.$trackDependency(path)` | Manually register a dependency for cache invalidation |
773
+ | `this.$command.<name>` | Access a `CommandToken` declared in `$commandTokens` (see [Command Token](#command-token-method-binding)) |
774
+ | `this.$stateElement` | Access to the `IStateElement` instance |
775
+ | `this.$1`, `this.$2`, ... | Current loop index (1-based naming, 0-based value) |
776
+
777
+ #### `$getAll` — Aggregate Across Array Elements
778
+
779
+ `$getAll` collects all values that match a wildcard path, returning them as an array. Essential for aggregation patterns:
780
+
781
+ ```javascript
782
+ export default {
783
+ scores: [85, 92, 78, 95, 88],
784
+ get average() {
785
+ const all = this.$getAll("scores.*", []);
786
+ return all.reduce((sum, v) => sum + v, 0) / all.length;
787
+ },
788
+ get max() {
789
+ return Math.max(...this.$getAll("scores.*", []));
790
+ }
791
+ };
792
+ ```
793
+
794
+ #### `$resolve` — Access by Explicit Index
795
+
796
+ `$resolve` reads or writes a value at a specific wildcard index:
797
+
798
+ ```javascript
799
+ export default {
800
+ items: ["A", "B", "C"],
801
+ swapFirstTwo() {
802
+ const a = this.$resolve("items.*", [0]);
803
+ const b = this.$resolve("items.*", [1]);
804
+ this.$resolve("items.*", [0], b);
805
+ this.$resolve("items.*", [1], a);
806
+ }
807
+ };
808
+ ```
809
+
810
+ ## Event Handling
811
+
812
+ Bind event handlers with `on*` properties:
813
+
814
+ ```html
815
+ <button data-wcs="onclick: handleClick">Click me</button>
816
+ <form data-wcs="onsubmit#prevent: handleSubmit">...</form>
817
+ ```
818
+
819
+ Handler methods receive the event and loop indexes:
820
+
821
+ ```javascript
822
+ export default {
823
+ items: ["A", "B", "C"],
824
+ handleClick(event) {
825
+ console.log("clicked");
826
+ },
827
+ removeItem(event, index) {
828
+ // index is the loop context ($1)
829
+ this.items = this.items.toSpliced(index, 1);
830
+ }
831
+ };
832
+ ```
833
+
834
+ ```html
835
+ <template data-wcs="for: items">
836
+ <button data-wcs="onclick: removeItem">Delete</button>
837
+ </template>
838
+ ```
839
+
840
+ ## Filters
841
+
842
+ 40 built-in filters are available for both input (DOM → state) and output (state → DOM) directions.
843
+
844
+ ### Comparison
845
+
846
+ | Filter | Description | Example |
847
+ |---|---|---|
848
+ | `eq(value)` | Equal | `count\|eq(0)` → `true/false` |
849
+ | `ne(value)` | Not equal | `count\|ne(0)` |
850
+ | `not` | Boolean NOT | `isActive\|not` |
851
+ | `lt(n)` | Less than | `count\|lt(10)` |
852
+ | `le(n)` | Less than or equal | `count\|le(10)` |
853
+ | `gt(n)` | Greater than | `count\|gt(0)` |
854
+ | `ge(n)` | Greater than or equal | `count\|ge(0)` |
855
+
856
+ ### Arithmetic
857
+
858
+ | Filter | Description | Example |
859
+ |---|---|---|
860
+ | `inc(n)` | Add | `count\|inc(1)` |
861
+ | `dec(n)` | Subtract | `count\|dec(1)` |
862
+ | `mul(n)` | Multiply | `price\|mul(1.1)` |
863
+ | `div(n)` | Divide | `total\|div(100)` |
864
+ | `mod(n)` | Modulo | `index\|mod(2)` |
865
+
866
+ ### Number Formatting
867
+
868
+ | Filter | Description | Example |
869
+ |---|---|---|
870
+ | `fix(n)` | Fixed decimal places | `price\|fix(2)` → `"100.00"` |
871
+ | `round(n?)` | Round | `value\|round(2)` |
872
+ | `floor(n?)` | Floor | `value\|floor` |
873
+ | `ceil(n?)` | Ceiling | `value\|ceil` |
874
+ | `locale(loc?)` | Locale number format | `count\|locale` / `count\|locale(ja-JP)` |
875
+ | `percent(n?)` | Percentage format | `ratio\|percent(1)` |
876
+
877
+ ### String
878
+
879
+ | Filter | Description | Example |
880
+ |---|---|---|
881
+ | `uc` | Upper case | `name\|uc` |
882
+ | `lc` | Lower case | `name\|lc` |
883
+ | `cap` | Capitalize | `name\|cap` |
884
+ | `trim` | Trim whitespace | `text\|trim` |
885
+ | `slice(n)` | Slice string | `text\|slice(5)` |
886
+ | `substr(start, length)` | Substring | `text\|substr(0,10)` |
887
+ | `pad(n, char?)` | Pad start | `id\|pad(5,0)` → `"00001"` |
888
+ | `rep(n)` | Repeat | `text\|rep(3)` |
889
+ | `rev` | Reverse | `text\|rev` |
890
+
891
+ ### Type Conversion
892
+
893
+ | Filter | Description | Example |
894
+ |---|---|---|
895
+ | `int` | Parse integer | `input\|int` |
896
+ | `float` | Parse float | `input\|float` |
897
+ | `boolean` | To boolean | `value\|boolean` |
898
+ | `number` | To number | `value\|number` |
899
+ | `string` | To string | `value\|string` |
900
+ | `null` | To null | `value\|null` |
901
+
902
+ ### Date / Time
903
+
904
+ | Filter | Description | Example |
905
+ |---|---|---|
906
+ | `date(loc?)` | Date format | `timestamp\|date` / `timestamp\|date(ja-JP)` |
907
+ | `time(loc?)` | Time format | `timestamp\|time` |
908
+ | `datetime(loc?)` | Date + Time | `timestamp\|datetime(en-US)` |
909
+ | `ymd(sep?)` | YYYY-MM-DD | `timestamp\|ymd` / `timestamp\|ymd(/)` |
910
+
911
+ ### Boolean / Default
912
+
913
+ | Filter | Description | Example |
914
+ |---|---|---|
915
+ | `truthy` | Truthy check | `value\|truthy` |
916
+ | `falsy` | Falsy check | `value\|falsy` |
917
+ | `defaults(v)` | Fallback value | `name\|defaults(Anonymous)` |
918
+
919
+ ### Filter Chaining
920
+
921
+ Filters can be chained with `|`:
922
+
923
+ ```html
924
+ <div data-wcs="textContent: price|mul(1.1)|round(2)|locale(ja-JP)"></div>
925
+ ```
926
+
927
+ ## Web Component Binding
928
+
929
+ `@wcstack/state` supports bidirectional state binding with custom elements using Shadow DOM or Light DOM.
930
+
931
+ 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:
932
+
933
+ 1. The child references and updates the parent's state through its own state proxy — no props, no events, no awareness of the parent.
934
+ 2. When the parent's state changes, the Proxy `set` trap automatically notifies any child bindings that reference the affected path.
935
+ 3. Because the only coupling is the **path name**, both sides remain loosely coupled and independently testable.
936
+ 4. The cost is path resolution (cached at O(1) after first access) plus change propagation through the dependency graph.
937
+
938
+ This provides a lightweight approach to cross-component state management based on path resolution rather than component-level abstractions.
939
+
940
+ ### Component Definition (Shadow DOM)
941
+
942
+ ```javascript
943
+ class MyComponent extends HTMLElement {
944
+ state = { message: "" };
945
+
946
+ constructor() {
947
+ super();
948
+ this.attachShadow({ mode: "open" });
949
+ this.shadowRoot.innerHTML = `
950
+ <wcs-state bind-component="state"></wcs-state>
951
+ <div>{{ message }}</div>
952
+ <input type="text" data-wcs="value: message" />
953
+ `;
954
+ }
955
+ }
956
+ customElements.define("my-component", MyComponent);
957
+ ```
958
+
959
+ ### Component Definition (Light DOM)
960
+
961
+ 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.
962
+
963
+ ```javascript
964
+ class MyLightComponent extends HTMLElement {
965
+ state = { message: "" };
966
+
967
+ connectedCallback() {
968
+ this.innerHTML = `
969
+ <wcs-state bind-component="state" name="my-light"></wcs-state>
970
+ <div data-wcs="text: message@my-light"></div>
971
+ <input type="text" data-wcs="value: message@my-light" />
972
+ `;
973
+ }
974
+ }
975
+ customElements.define("my-light-component", MyLightComponent);
976
+ ```
977
+
978
+ - `name` attribute is **required** for Light DOM components (namespace is shared with the parent scope)
979
+ - Bindings must explicitly reference the state name with `@my-light`
980
+ - `<wcs-state>` must be a direct child of the component element
981
+
982
+ ### Host Usage
983
+
984
+ ```html
985
+ <wcs-state>
986
+ <script type="module">
987
+ export default {
988
+ user: { name: "Alice" }
989
+ };
990
+ </script>
991
+ </wcs-state>
992
+
993
+ <!-- Bind component's state.message to outer user.name -->
994
+ <my-component data-wcs="state.message: user.name"></my-component>
995
+ ```
996
+
997
+ - `bind-component="state"` maps the component's `state` property to `<wcs-state>`
998
+ - `data-wcs="state.message: user.name"` on the host element binds outer state paths to inner component state properties
999
+ - Changes propagate bidirectionally between the component and the outer state
1000
+
1001
+ ### Standalone Web Component Injection (`__e2e__/single-component`)
1002
+
1003
+ Even when a component is independent from outer host state, you can inject reactive state with `bind-component`.
1004
+
1005
+ ```javascript
1006
+ class MyComponent extends HTMLElement {
1007
+ state = Object.freeze({
1008
+ message: "Hello, World!"
1009
+ });
1010
+
1011
+ constructor() {
1012
+ super();
1013
+ this.attachShadow({ mode: "open" });
1014
+ }
1015
+
1016
+ connectedCallback() {
1017
+ this.shadowRoot.innerHTML = `
1018
+ <wcs-state bind-component="state"></wcs-state>
1019
+ <div>{{ message }}</div>
1020
+ `;
1021
+ }
1022
+
1023
+ async $stateReadyCallback(stateProp) {
1024
+ console.log("state ready:", stateProp); // "state"
1025
+ }
1026
+ }
1027
+ customElements.define("my-component", MyComponent);
1028
+ ```
1029
+
1030
+ - Initial component `state` can be defined with `Object.freeze(...)` (it is replaced with a writable reactive state after injection)
1031
+ - `bind-component="state"` exposes `this.state` as a state proxy powered by `@wcstack/state`
1032
+ - Assignments like `this.state.message = "..."` immediately update `{{ message }}` inside Shadow DOM
1033
+ - `async $stateReadyCallback(stateProp)` is called right after component state becomes ready for use (`stateProp` is the property name from `bind-component`)
1034
+
1035
+ ### Constraints
1036
+
1037
+ - `<wcs-state>` with `bind-component` must be a **direct child** of the component element (top-level)
1038
+ - The parent element must be a **custom element** (tag name containing a hyphen)
1039
+ - Light DOM components **require** a `name` attribute to avoid namespace conflicts with the parent scope
1040
+ - Light DOM bindings must reference the state name explicitly (e.g., `@my-light`)
1041
+
1042
+ ### Loop with Components
1043
+
1044
+ ```html
1045
+ <template data-wcs="for: users">
1046
+ <my-component data-wcs="state.message: .name"></my-component>
1047
+ </template>
1048
+ ```
1049
+
1050
+ ## Command Token (Method Binding)
1051
+
1052
+ 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:
1053
+
1054
+ - The element subscribes via `command.<methodName>: $command.<tokenName>`
1055
+ - State emits via `this.$command.<tokenName>.emit(...args)`
1056
+ - Arguments passed to `emit` are forwarded to the element's method
1057
+ - One token can fan out to multiple elements; the subscriber order is preserved
1058
+
1059
+ 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.
1060
+
1061
+ ### Basic Usage
1062
+
1063
+ ```html
1064
+ <wcs-state>
1065
+ <script type="module">
1066
+ export default {
1067
+ $commandTokens: ["fetchUsers", "refreshOrders"],
1068
+
1069
+ onClickFetch() {
1070
+ this.$command.fetchUsers.emit("/api/users", { method: "GET" });
1071
+ },
1072
+ onClickRefresh() {
1073
+ this.$command.refreshOrders.emit();
1074
+ }
1075
+ };
1076
+ </script>
1077
+ </wcs-state>
1078
+
1079
+ <!-- Subscribers — must be wc-bindable custom elements -->
1080
+ <wcs-fetch data-wcs="command.fetch: $command.fetchUsers"></wcs-fetch>
1081
+ <wcs-fetch data-wcs="command.fetch: $command.refreshOrders"></wcs-fetch>
1082
+
1083
+ <button data-wcs="onclick: onClickFetch">Fetch users</button>
1084
+ <button data-wcs="onclick: onClickRefresh">Refresh orders</button>
1085
+ ```
1086
+
1087
+ When `onClickFetch` runs, every element subscribed to the `fetchUsers` token has its `fetch(...)` method called with the forwarded arguments.
1088
+
1089
+ ### `$commandTokens` Declaration
1090
+
1091
+ 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.
1092
+
1093
+ ```javascript
1094
+ export default {
1095
+ $commandTokens: ["fetchUsers", "refreshOrders"],
1096
+
1097
+ click() {
1098
+ this.$command.fetchUsers.emit("/api/users");
1099
+ }
1100
+ };
1101
+ ```
1102
+
1103
+ - Entries must be non-empty strings
1104
+ - Duplicate entries throw an error at initialization
1105
+ - The reserved name `$command` itself cannot appear in the array
1106
+ - 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
1107
+ - 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
1108
+
1109
+ ### `command.<methodName>:` Binding
1110
+
1111
+ ```html
1112
+ <wcs-fetch data-wcs="command.fetch: $command.fetchUsers"></wcs-fetch>
1113
+ ```
1114
+
1115
+ | Part | Description |
1116
+ |---|---|
1117
+ | `command.` | Fixed prefix |
1118
+ | `<methodName>` | The element's method to invoke. Must be listed in `static wcBindable.commands` |
1119
+ | `$command.<tokenName>` | Explicit namespace path that resolves to a `CommandToken`. `<tokenName>` must be a name declared in `$commandTokens` |
1120
+
1121
+ 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.
1122
+
1123
+ Validation rules (enforced at binding time):
1124
+
1125
+ - The element must be a custom element exposing `static wcBindable` with `protocol: "wc-bindable"` and `version: 1`
1126
+ - `methodName` must be present in `wcBindable.commands`
1127
+ - 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)
1128
+
1129
+ ### Token API
1130
+
1131
+ ```typescript
1132
+ interface CommandToken {
1133
+ readonly name: string;
1134
+ readonly size: number; // current subscriber count
1135
+ subscribe(fn: (...args) => unknown): () => void; // returns unsubscribe
1136
+ unsubscribe(fn: (...args) => unknown): boolean;
1137
+ emit(...args: unknown[]): unknown[]; // returns subscriber results in subscribe order
1138
+ }
1139
+ ```
1140
+
1141
+ `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.
1142
+
1143
+ ### Subscription Lifecycle
1144
+
1145
+ - 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
1146
+ - On `emit`, if the WeakRef has been collected or the element is no longer connected (`isConnected === false`), the subscription is purged automatically (lazy purge)
1147
+ - When the owning `<wcs-state>` is disconnected, the entire token registry is cleared
1148
+
1149
+ The element's method is invoked with the arguments from `emit`:
1150
+
1151
+ ```javascript
1152
+ this.$command.fetchUsers.emit(url, options);
1153
+ // → element.fetch(url, options) on every subscriber
1154
+ ```
1155
+
1156
+ ## Declarative Custom Components (DCC)
1157
+
1158
+ 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.
1159
+
1160
+ ### Basic Definition
1161
+
1162
+ ```html
1163
+ <!-- 1. Define the component (hidden by CSS) -->
1164
+ <my-counter data-wc-definition>
1165
+ <template shadowrootmode="open">
1166
+ <p>{{ count }}</p>
1167
+ <button data-wcs="onclick: increment">+1</button>
1168
+ <wcs-state>
1169
+ <script type="module">
1170
+ export default {
1171
+ count: 0,
1172
+ increment() { this.count++; },
1173
+ $bindables: ["count"]
1174
+ };
1175
+ </script>
1176
+ </wcs-state>
1177
+ </template>
1178
+ </my-counter>
1179
+
1180
+ <!-- 2. Use it — each instance gets its own state -->
1181
+ <my-counter></my-counter>
1182
+ <my-counter></my-counter>
1183
+ ```
1184
+
1185
+ When `<wcs-state>` detects it is inside a `data-wc-definition` host, it:
1186
+
1187
+ 1. Loads the state object (from `<script type="module">` or `src="*.js"`)
1188
+ 2. Generates a custom element class with getter/setter/method properties on the prototype
1189
+ 3. Registers it via `customElements.define()`
1190
+
1191
+ The definition element is hidden; each instance clones the template into its own Shadow DOM and initializes its own `<wcs-state>`.
1192
+
1193
+ ### Recommended CSS
1194
+
1195
+ ```css
1196
+ :not(:defined) { display: none; }
1197
+ [data-wc-definition] { display: none; }
1198
+ ```
1199
+
1200
+ ### `$bindables` and wc-bindable Protocol
1201
+
1202
+ The `$bindables` array declares which state properties are exposed as component properties with change events, following the [wc-bindable protocol](https://github.com/nicenemo/nicenemo/blob/main/docs/wc-bindable-protocol.md):
1203
+
1204
+ ```javascript
1205
+ export default {
1206
+ count: 0,
1207
+ increment() { this.count++; },
1208
+ $bindables: ["count"]
1209
+ };
1210
+ ```
1211
+
1212
+ This generates:
1213
+
1214
+ - `static wcBindable` on the class protocol metadata for framework adapters
1215
+ - Getter/setter on the prototype — reads/writes go through the reactive proxy
1216
+ - `CustomEvent` dispatch — `my-counter:count-changed` fires on every mutation
1217
+
1218
+ ### Binding to DCC Properties
1219
+
1220
+ Other `<wcs-state>` instances can bind to DCC properties just like any Web Component:
1221
+
1222
+ ```html
1223
+ <my-counter data-wcs="count: parentCount"></my-counter>
1224
+
1225
+ <wcs-state>
1226
+ <script type="module">
1227
+ export default { parentCount: 0 };
1228
+ </script>
1229
+ </wcs-state>
1230
+ <div data-wcs="textContent: parentCount"></div>
1231
+ ```
1232
+
1233
+ ### Shadow Root Mode
1234
+
1235
+ Both `open` and `closed` modes are supported:
1236
+
1237
+ ```html
1238
+ <my-component data-wc-definition>
1239
+ <template shadowrootmode="closed">
1240
+ <!-- closed shadow DOM -->
1241
+ </template>
1242
+ </my-component>
1243
+ ```
1244
+
1245
+ ### Internal Properties
1246
+
1247
+ Properties prefixed with `$` are internal and not exposed on the component prototype:
1248
+
1249
+ | Property | Purpose |
1250
+ |----------|---------|
1251
+ | `$bindables` | Declares observable properties |
1252
+ | `$connectedCallback` | Lifecycle hook (runs on each instance) |
1253
+ | `$disconnectedCallback` | Cleanup hook |
1254
+ | `$updatedCallback` | Called after state mutations |
1255
+
1256
+ ## SVG Support
1257
+
1258
+ All bindings work inside `<svg>` elements. Use `attr.*` for SVG attributes:
1259
+
1260
+ ```html
1261
+ <svg width="200" height="100">
1262
+ <template data-wcs="for: points">
1263
+ <circle data-wcs="attr.cx: .x; attr.cy: .y; attr.fill: .color" r="5" />
1264
+ </template>
1265
+ </svg>
1266
+ ```
1267
+
1268
+ ## Lifecycle Hooks
1269
+
1270
+ State objects can define `$connectedCallback`, `$disconnectedCallback`, and `$updatedCallback` for initialization, cleanup, and update lifecycle handling.
1271
+
1272
+ ```html
1273
+ <wcs-state>
1274
+ <script type="module">
1275
+ export default {
1276
+ timer: null,
1277
+ count: 0,
1278
+
1279
+ // Called when <wcs-state> is connected to the DOM
1280
+ async $connectedCallback() {
1281
+ const res = await fetch("/api/initial-count");
1282
+ this.count = await res.json();
1283
+ this.timer = setInterval(() => { this.count++; }, 1000);
1284
+ },
1285
+
1286
+ // Called when <wcs-state> is disconnected from the DOM (sync only)
1287
+ $disconnectedCallback() {
1288
+ clearInterval(this.timer);
1289
+ }
1290
+ };
1291
+ </script>
1292
+ </wcs-state>
1293
+ ```
1294
+
1295
+ | Hook | Timing | Async |
1296
+ |---|---|---|
1297
+ | `$connectedCallback` | After state initialization on first connect; on every reconnect thereafter | Yes (awaited) |
1298
+ | `$disconnectedCallback` | When the element is removed from the DOM | No (sync only) |
1299
+ | `$updatedCallback(paths, indexesListByPath)` | After state updates are applied | Yes (not awaited) |
1300
+
1301
+ 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.
1302
+
1303
+ - `this` inside hooks is the state proxy with full read/write access
1304
+ - `$connectedCallback` is called **every time** the element is connected (including re-insertion after removal), making it suitable for setup that should be re-established
1305
+ - `$disconnectedCallback` is called synchronously — use it for cleanup such as clearing timers, removing event listeners, or releasing resources
1306
+ - `$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
1307
+ - In Web Components, define `async $stateReadyCallback(stateProp)` to receive a hook when the bound state becomes available via `bind-component`
1308
+
1309
+ ## Configuration
1310
+
1311
+ Pass a partial configuration object to `bootstrapState()`:
1312
+
1313
+ ```javascript
1314
+ import { bootstrapState } from '@wcstack/state';
1315
+
1316
+ bootstrapState({
1317
+ locale: 'ja-JP',
1318
+ debug: true,
1319
+ enableMustache: false,
1320
+ tagNames: { state: 'my-state' },
1321
+ });
1322
+ ```
1323
+
1324
+ All options with defaults:
1325
+
1326
+ | Option | Default | Description |
1327
+ |---|---|---|
1328
+ | `bindAttributeName` | `'data-wcs'` | Binding attribute name |
1329
+ | `tagNames.state` | `'wcs-state'` | State element tag name |
1330
+ | `locale` | `'en'` | Default locale for filters |
1331
+ | `debug` | `false` | Debug mode |
1332
+ | `enableMustache` | `true` | Enable `{{ }}` syntax |
1333
+
1334
+ ## TypeScript Support
1335
+
1336
+ `defineState()` wraps your state object and provides type-safe `this` inside methods and getters with zero runtime cost (identity function).
1337
+
1338
+ ```typescript
1339
+ import { defineState } from '@wcstack/state';
1340
+
1341
+ export default defineState({
1342
+ count: 0,
1343
+ users: [] as { name: string; age: number }[],
1344
+
1345
+ increment() {
1346
+ this.count++; // number
1347
+ this["users.*.name"]; // ✅ string (dot-path resolution)
1348
+ this.$getAll("users.*.age", []); // ✅ API method
1349
+ },
1350
+
1351
+ get "users.*.ageCategory"() {
1352
+ return this["users.*.age"] < 25 ? "Young" : "Adult";
1353
+ }
1354
+ });
1355
+ ```
1356
+
1357
+ Utility types `WcsPaths<T>` and `WcsPathValue<T, P>` are also exported for advanced use cases. See [docs/define-state.md](docs/define-state.md) for full documentation.
1358
+
1359
+ ## API Reference
1360
+
1361
+ ### `bootstrapState()`
1362
+
1363
+ Initialize the state system. Registers `<wcs-state>` custom element and sets up DOM content loaded handler.
1364
+
1365
+ ```javascript
1366
+ import { bootstrapState } from '@wcstack/state';
1367
+ bootstrapState();
1368
+ ```
1369
+
1370
+ ### `<wcs-state>` Element
1371
+
1372
+ | Attribute | Description |
1373
+ |---|---|
1374
+ | `name` | State name (default: `"default"`) |
1375
+ | `state` | ID of a `<script type="application/json">` element |
1376
+ | `src` | URL to `.json` or `.js` file |
1377
+ | `json` | Inline JSON string |
1378
+ | `bind-component` | Property name for web component binding |
1379
+
1380
+ ### IStateElement
1381
+
1382
+ | Property / Method | Description |
1383
+ |---|---|
1384
+ | `name` | State name |
1385
+ | `initializePromise` | Resolves when state is fully initialized |
1386
+ | `listPaths` | Set of paths used in `for` loops |
1387
+ | `getterPaths` | Set of paths defined as getters |
1388
+ | `setterPaths` | Set of paths defined as setters |
1389
+ | `createState(mutability, callback)` | Create a state proxy (`"readonly"` or `"writable"`) |
1390
+ | `createStateAsync(mutability, callback)` | Async version of `createState` |
1391
+ | `setInitialState(state)` | Set state programmatically (before initialization) |
1392
+ | `bindProperty(prop, descriptor)` | Define a property on the raw state object |
1393
+ | `nextVersion()` | Increment and return version number |
1394
+
1395
+ ## Architecture
1396
+
1397
+ ```
1398
+ bootstrapState()
1399
+ └── registerComponents() // Register <wcs-state> custom element
1400
+
1401
+ <wcs-state> connectedCallback
1402
+ ├── _initializeBindWebComponent() // bind-component: get state from parent component
1403
+ ├── _initialize() // Load state (state attr / src / json / script / API)
1404
+ │ └── setStateElementByName() // Register to WeakMap<Node, Map<name, element>>
1405
+ │ └── (first registration per rootNode)
1406
+ │ └── queueMicrotask → buildBindings()
1407
+ ├── _callStateConnectedCallback() // Call $connectedCallback if defined
1408
+
1409
+ buildBindings(root)
1410
+ ├── waitForStateInitialize() // Wait for all <wcs-state> initializePromise
1411
+ ├── convertMustacheToComments() // {{ }} → comment nodes
1412
+ ├── collectStructuralFragments() // Collect for/if templates
1413
+ └── initializeBindings() // Walk DOM, parse data-wcs, set up bindings
1414
+ ```
1415
+
1416
+ ### Reactivity Flow
1417
+
1418
+ 1. State changes via Proxy `set` trap → `setByAddress()`
1419
+ 2. Address resolved → updater enqueues absolute address
1420
+ 3. Dependency walker invalidates (dirties) downstream caches
1421
+ 4. Updater applies changes to bound DOM nodes via `applyChangeFromBindings()`
1422
+
1423
+ ### State Address System
1424
+
1425
+ Paths like `users.*.name` are decomposed into:
1426
+
1427
+ - **PathInfo** — static path metadata (segments, wildcard count, parent path)
1428
+ - **ListIndex** — runtime loop index chain
1429
+ - **StateAddress** — combination of PathInfo + ListIndex
1430
+ - **AbsoluteStateAddress** — state name + StateAddress (for cross-state references)
1431
+
1432
+ ## Server-Side Rendering
1433
+
1434
+ `@wcstack/state` supports SSR via the companion [`@wcstack/server`](../server/) package. The same templates you write for the client render on the server — no changes needed.
1435
+
1436
+ ### Quick Setup
1437
+
1438
+ 1. Add `enable-ssr` to your `<wcs-state>` element:
1439
+
1440
+ ```html
1441
+ <wcs-state enable-ssr>
1442
+ <script type="module">
1443
+ export default {
1444
+ items: [],
1445
+ async $connectedCallback() {
1446
+ const res = await fetch("/api/items");
1447
+ this.items = await res.json();
1448
+ }
1449
+ };
1450
+ </script>
1451
+ </wcs-state>
1452
+ <template data-wcs="for: items">
1453
+ <div data-wcs="textContent: items.*.name"></div>
1454
+ </template>
1455
+ ```
1456
+
1457
+ 2. Render on the server:
1458
+
1459
+ ```javascript
1460
+ import { renderToString } from "@wcstack/server";
1461
+
1462
+ const html = await renderToString(template, {
1463
+ baseUrl: "http://localhost:3000"
1464
+ });
1465
+ ```
1466
+
1467
+ 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.
1468
+
1469
+ ### How It Works
1470
+
1471
+ | Phase | What happens |
1472
+ |-------|-------------|
1473
+ | **Server** | `renderToString()` runs your template in happy-dom, executes `$connectedCallback` (including `fetch()`), applies all bindings, and outputs rendered HTML with a `<wcs-ssr>` element containing hydration data |
1474
+ | **Client** | `<wcs-state enable-ssr>` loads state from `<wcs-ssr>` JSON, skips `$connectedCallback`, and `hydrateBindings()` wires up reactivity on the existing DOM |
1475
+ | **Fallback** | If server/client versions mismatch, the SSR DOM is cleaned up and `buildBindings()` runs a full client-side render |
1476
+
1477
+ ### What `enable-ssr` Does
1478
+
1479
+ | Context | Behavior |
1480
+ |---------|----------|
1481
+ | **Server** (`renderToString`) | Generates `<wcs-ssr>` with state JSON, template fragments, and property data |
1482
+ | **Client** (hydration) | Reads `<wcs-ssr>`, restores state, skips `$connectedCallback`, hydrates bindings on existing DOM |
1483
+
1484
+ See [`@wcstack/server` README](../server/README.md) for full API documentation.
1485
+
1486
+ ## License
1487
+
1488
+ MIT