stave.dev

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:

How Getters Work

When you read an observable property, the getter follows a priority chain:

Check attribute
Check slot elements
Parse if HTML string
Return value
  1. If the element has an HTML attribute with that name, return its value
  2. If not, look for <slot> elements with a matching slot attribute
  3. If the value looks like HTML (starts with < and ends with >), parse and return as an HTMLElement
  4. 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>
Slot Tag Type

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>
JSON Round-Trip

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