Observable Properties
Declare reactive properties once. ShadowComponent creates the getters, setters, and slot synchronization for you.
Declaring Observables
List your component's reactive properties in a static observables array. The framework uses this list to create Object.defineProperty getter/setter pairs and to wire up observedAttributes:
class UserCard extends ShadowComponent {
static observables = ["name", "email", "avatar"];
}
That single declaration gives you:
- Getters that read from attributes or slotted content
- Setters that write to attributes (triggering
attributeChangedCallback) - Automatic slot synchronization when values change
- JSON parsing for objects and arrays
- HTML element serialization for DOM values
How Getters Work
When you read an observable property, the getter follows a priority chain:
- If the element has an HTML attribute with that name, return its value
- If not, look for
<slot>elements with a matchingslotattribute - If the value looks like HTML (starts with
<and ends with>), parse and return as an HTMLElement - If multiple slot elements exist, return the NodeList
// Reading observable values
const card = document.querySelector("user-card");
// These all work:
console.log(card.name); // reads from attribute or slot
console.log(card.email); // reads from attribute or slot
console.log(card.avatar); // reads from attribute or slot
How Setters Work
When you set an observable property, the setter converts the value to a string and writes it as an HTML attribute. This triggers attributeChangedCallback, which synchronizes the corresponding slot.
// Setting observable values
card.name = "Alice"; // setAttribute("name", "Alice")
card.email = "alice@example.com"; // setAttribute("email", "alice@example.com")
// Objects and arrays are JSON-serialized:
card.tags = ["admin", "editor"]; // setAttribute("tags", '["admin","editor"]')
// HTMLElements are serialized:
const img = document.createElement("img");
img.src = "/photo.jpg";
card.avatar = img; // setAttribute("avatar", "<img src=\"/photo.jpg\">")
Slot Synchronization
The magic of observables is the connection between attributes and slots. When an observable attribute changes, attributeChangedCallback finds (or creates) elements with slot="propertyName" and updates their content.
<template id="user-card">
<style>
:host { display: block; padding: 1rem; }
.name { font-weight: bold; color: var(--text-bright); }
</style>
<div class="name"><slot name="name">Anonymous</slot></div>
<div class="email"><slot name="email">No email</slot></div>
</template>
The text inside each <slot> acts as a default value. When slotted content is provided (either from HTML or from a property change), it replaces the default.
<!-- With default values (shows "Anonymous" and "No email") -->
<user-card></user-card>
<!-- With slotted content -->
<user-card>
<span slot="name">Alice</span>
<span slot="email">alice@example.com</span>
</user-card>
<!-- Or set via JavaScript -->
<script>
document.querySelector("user-card").name = "Bob";
// Creates <span slot="name">Bob</span> automatically
</script>
By default, dynamically created slot elements use <span>. Add a tagname attribute to the <slot> in your template to control this: <slot name="items" tagname="li"></slot>
The Update Algorithm
When attributeChangedCallback fires, the framework decides between two strategies:
| Strategy | When | What It Does |
|---|---|---|
| Patch | Same number of elements, all primitive values | Updates innerHTML of existing elements in place |
| Recreate | Count changed, or complex values | Removes existing slot elements, creates new ones |
Patching preserves user state (focus, selection) whenever possible. The recreate path provides a clean slate when the structure changes.
Custom Property Transformations
Because observable getter/setters are defined with configurable: true, you can override them in the constructor for custom behavior:
class ProgressBar extends ShadowComponent {
static observables = ["value"];
constructor() {
super();
// Override the default setter to clamp values
Object.defineProperty(this, "value", {
get() {
return parseFloat(this.getAttribute("value")) || 0;
},
set(val) {
// Clamp between 0 and 100
val = Math.max(0, Math.min(100, parseFloat(val) || 0));
this.setAttribute("value", val);
},
configurable: true
});
}
connectedCallback() {
super.connectedCallback();
this._bar = this.shadowRoot.querySelector(".bar");
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === "value" && this._bar) {
this._bar.style.width = newValue + "%";
}
}
}
customElements.define("progress-bar", ProgressBar);
Array Values
Setting an observable to an array creates one slot element per item. This is useful for lists:
class TagList extends ShadowComponent {
static observables = ["tags"];
}
customElements.define("tag-list", TagList);
// In the template:
// <slot name="tags" tagname="li"></slot>
// Usage:
const list = document.querySelector("tag-list");
list.tags = ["JavaScript", "Web Components", "CSS"];
// Creates: <li slot="tags">JavaScript</li>
// <li slot="tags">Web Components</li>
// <li slot="tags">CSS</li>
Arrays and objects are JSON-serialized when set as attributes, then parsed back by the getter. This means your data must be JSON-serializable — no functions, no circular references, no DOM nodes (unless they're HTMLElements, which get XML-serialized).