Skip to content

Attribute Property Sync

This lesson explains Attribute Property Sync with clear examples, use cases, and best practices so you can build reusable Web Components confidently.

Web Components Attribute Property Sync Overview

Attribute and property sync means keeping HTML attributes and JavaScript properties consistent in a Web Component. Attributes are string-based values written in HTML, while properties are JavaScript values set directly on the element object.

Good synchronization lets a component work from plain HTML, JavaScript, frameworks, server-rendered markup, documentation examples, and design system demos.

Attribute Property Sync List

  • 1. Understand attributes.
    Attributes are written in HTML and are always stored as strings.
    <my-button
      label="Save"
      variant="primary"
      disabled>
    </my-button>
  • 2. Understand properties.
    Properties are JavaScript values set directly on the custom element object. They can store strings, booleans, numbers, arrays, objects, and functions.
    const button =
      document.querySelector("my-button");
    
    button.label =
      "Save";
    
    button.disabled =
      true;
  • 3. Use attributes for simple public configuration.
    Attributes work best for simple values such as labels, variants, sizes, open states, selected states, disabled states, and IDs.
    <my-alert
      type="success"
      dismissible
      message="Saved successfully">
    </my-alert>
  • 4. Use properties for complex data.
    Use properties for arrays, objects, callbacks, service instances, dates, maps, sets, and large data structures.
    const table =
      document.querySelector("my-table");
    
    table.rows =
      [
        { id: 1, name: "Alice" },
        { id: 2, name: "Bob" }
      ];
    
    table.onRowSelect =
      (row) => {
        console.log(row);
      };
  • 5. Reflect properties to attributes when useful.
    Property setters can update attributes so the DOM reflects the current component state.
    get label() {
      return this.getAttribute("label") || "";
    }
    
    set label(value) {
      this.setAttribute(
        "label",
        value
      );
    }
  • 6. Reflect boolean properties correctly.
    Boolean attributes are true when present and false when absent.
    get disabled() {
      return this.hasAttribute("disabled");
    }
    
    set disabled(value) {
      if (value) {
        this.setAttribute("disabled", "");
      } else {
        this.removeAttribute("disabled");
      }
    }
  • 7. Reflect numeric properties as strings.
    Attributes store strings, so numbers must be converted.
    get maxCount() {
      return Number(
        this.getAttribute("max-count") || 0
      );
    }
    
    set maxCount(value) {
      this.setAttribute(
        "max-count",
        String(value)
      );
    }
  • 8. Use kebab-case for attributes and camelCase for properties.
    HTML attributes usually use lowercase or kebab-case. JavaScript properties usually use camelCase.
    <my-counter max-count="10">
    </my-counter>
    counter.maxCount =
      20;
  • 9. Observe attributes that should update the component.
    Use observedAttributes for attributes that affect UI, state, accessibility, or behavior.
    static get observedAttributes() {
      return [
        "label",
        "variant",
        "disabled",
        "max-count"
      ];
    }
  • 10. Sync observed attributes in attributeChangedCallback().
    This callback keeps UI updated when attributes are added, changed, or removed.
    attributeChangedCallback(name, oldValue, newValue) {
      if (oldValue === newValue) {
        return;
      }
    
      this.update();
    }
  • 11. Avoid infinite loops during sync.
    Do not repeatedly set the same observed attribute from inside attributeChangedCallback() unless you guard against unchanged values.
    attributeChangedCallback(name, oldValue, newValue) {
      if (oldValue === newValue) {
        return;
      }
    
      this.renderChangedAttribute(
        name,
        newValue
      );
    }
  • 12. Normalize property values before reflecting.
    Clean up values before storing them in attributes.
    set variant(value) {
      const allowed =
        ["primary", "secondary", "danger"];
    
      const normalized =
        allowed.includes(value)
          ? value
          : "primary";
    
      this.setAttribute(
        "variant",
        normalized
      );
    }
  • 13. Provide defaults in getters.
    Getters should return safe defaults when the attribute is missing.
    get variant() {
      return this.getAttribute("variant") || "primary";
    }
    
    get label() {
      return this.getAttribute("label") || "Submit";
    }
  • 14. Sync accessibility attributes.
    When a property changes state, update related ARIA attributes too.
    set expanded(value) {
      if (value) {
        this.setAttribute("expanded", "");
      } else {
        this.removeAttribute("expanded");
      }
    
      this.setAttribute(
        "aria-expanded",
        String(Boolean(value))
      );
    }
  • 15. Keep internal controls synchronized.
    When the host property changes, update internal Shadow DOM controls.
    updateDisabled() {
      const button =
        this.shadowRoot.querySelector("button");
    
      if (button) {
        button.disabled =
          this.disabled;
      }
    }
  • 16. Use properties for non-string values.
    Attributes cannot directly store real arrays, objects, booleans, or functions without conversion.
    card.config =
      {
        compact: true,
        theme: "dark"
      };
  • 17. Avoid JSON attributes for large data.
    JSON attributes are fragile, hard to read, and error-prone. Prefer properties for complex data.
    table.rows =
      [
        { id: 1, name: "Alice" }
      ];
  • 18. Parse JSON attributes only when necessary.
    If you must support JSON attributes, parse safely.
    get config() {
      try {
        return JSON.parse(
          this.getAttribute("config") || "{}"
        );
      } catch (error) {
        return {};
      }
    }
  • 19. Upgrade pre-set properties.
    A property may be set before the custom element class is upgraded. Move that value through the setter after upgrade.
    upgradeProperty(propertyName) {
      if (Object.prototype.hasOwnProperty.call(this, propertyName)) {
        const value =
          this[propertyName];
    
        delete this[propertyName];
    
        this[propertyName] =
          value;
      }
    }
    
    connectedCallback() {
      this.upgradeProperty("label");
      this.upgradeProperty("disabled");
    }
  • 20. Sync initial HTML attributes after render.
    Initial attributes written in HTML should be applied after Shadow DOM is created.
    connectedCallback() {
      if (!this.shadowRoot) {
        this.attachShadow(
          { mode: "open" }
        );
    
        this.render();
      }
    
      this.updateFromAttributes();
    }
  • 21. Use internal state for private values.
    Not every property should reflect to an attribute. Keep private runtime values as internal fields.
    constructor() {
      super();
    
      this._isDragging =
        false;
    
      this._items =
        [];
    }
  • 22. Use attributes for CSS styling hooks.
    Reflected attributes are useful for styling host variants.
    :host([variant="primary"]) {
      display: inline-block;
    }
    
    :host([disabled]) {
      opacity: 0.6;
    }
  • 23. Avoid reflecting high-frequency values.
    Do not reflect rapidly changing values such as pointer positions, scroll positions, animation frames, or drag coordinates.
    // Prefer internal state instead of attributes
    
    this._pointerX =
      event.clientX;
    
    this._pointerY =
      event.clientY;
  • 24. Dispatch events for important property changes.
    When a public property changes because of user interaction, notify the outside world with a custom event.
    this.dispatchEvent(
      new CustomEvent(
        "value-change",
        {
          detail: {
            value: this.value
          },
          bubbles: true,
          composed: true
        }
      )
    );
  • 25. Do not dispatch events for every internal sync.
    Avoid firing events when attributes are only being initialized or reflected internally unless consumers need to know.
  • 26. Use setters for validation and rendering.
    Setters are a good place to normalize values, reflect attributes, and trigger updates.
    set value(nextValue) {
      const normalized =
        String(nextValue || "");
    
      if (normalized === this.value) {
        return;
      }
    
      this.setAttribute(
        "value",
        normalized
      );
    
      this.updateValue();
    }
  • 27. Keep public API predictable.
    Attribute and property names should be documented and behave consistently.
    <my-tabs
      selected-index="0">
    </my-tabs>
    tabs.selectedIndex =
      1;
  • 28. Test HTML usage and JavaScript usage.
    A reusable component should work when configured through markup and when configured through JavaScript.
    const alert =
      document.createElement("my-alert");
    
    alert.type =
      "success";
    
    alert.message =
      "Saved";
    
    document.body.appendChild(alert);
  • 29. Test property changes after connection.
    Components should update after they are already rendered.
    const button =
      document.querySelector("my-button");
    
    button.label =
      "Updated Label";
    
    button.disabled =
      true;
  • 30. Document attribute-property mapping.
    Design system documentation should show attribute names, property names, types, default values, allowed values, and whether values reflect.

Complete Attribute Property Sync Example

This example shows string, boolean, numeric, and complex property syncing with attribute reflection and safe rendering.

class MyCounter extends HTMLElement {
  static get observedAttributes() {
    return [
      "label",
      "count",
      "disabled"
    ];
  }

  constructor() {
    super();

    this._items =
      [];

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

    this.shadowRoot.innerHTML =
      `
        <style>
          button {
            padding: 0.5rem 0.75rem;
          }

          :host([disabled]) button {
            opacity: 0.6;
          }
        </style>

        <button type="button">
          <span class="label"></span>
          <span class="count"></span>
        </button>
      `;
  }

  connectedCallback() {
    this.upgradeProperty("label");
    this.upgradeProperty("count");
    this.upgradeProperty("disabled");
    this.upgradeProperty("items");

    this.update();

    this.shadowRoot
      .querySelector("button")
      .addEventListener("click", () => {
        if (!this.disabled) {
          this.count =
            this.count + 1;

          this.dispatchEvent(
            new CustomEvent(
              "count-change",
              {
                detail: {
                  count: this.count
                },
                bubbles: true,
                composed: true
              }
            )
          );
        }
      });
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue === newValue) {
      return;
    }

    this.update();
  }

  upgradeProperty(propertyName) {
    if (Object.prototype.hasOwnProperty.call(this, propertyName)) {
      const value =
        this[propertyName];

      delete this[propertyName];

      this[propertyName] =
        value;
    }
  }

  get label() {
    return this.getAttribute("label") || "Count";
  }

  set label(value) {
    this.setAttribute(
      "label",
      String(value)
    );
  }

  get count() {
    return Number(
      this.getAttribute("count") || 0
    );
  }

  set count(value) {
    const numberValue =
      Number(value);

    this.setAttribute(
      "count",
      Number.isNaN(numberValue)
        ? "0"
        : String(numberValue)
    );
  }

  get disabled() {
    return this.hasAttribute("disabled");
  }

  set disabled(value) {
    if (value) {
      this.setAttribute("disabled", "");
    } else {
      this.removeAttribute("disabled");
    }
  }

  get items() {
    return this._items;
  }

  set items(value) {
    this._items =
      Array.isArray(value)
        ? value
        : [];

    this.update();
  }

  update() {
    if (!this.shadowRoot) {
      return;
    }

    const label =
      this.shadowRoot.querySelector(".label");

    const count =
      this.shadowRoot.querySelector(".count");

    const button =
      this.shadowRoot.querySelector("button");

    if (!label || !count || !button) {
      return;
    }

    label.textContent =
      this.label + ": ";

    count.textContent =
      String(this.count);

    button.disabled =
      this.disabled;

    this.setAttribute(
      "aria-disabled",
      String(this.disabled)
    );
  }
}

customElements.define(
  "my-counter",
  MyCounter
);
<my-counter
  label="Clicks"
  count="5">
</my-counter>
const counter =
  document.querySelector("my-counter");

counter.count =
  10;

counter.disabled =
  true;

counter.items =
  [
    { id: 1, label: "One" }
  ];

Attribute and Property Mapping Table

Value Type Attribute Example Property Example Best Practice
String label="Save" el.label = "Save" Safe to reflect.
Boolean disabled el.disabled = true Presence means true.
Number count="5" el.count = 5 Convert with Number().
Array Not recommended. el.items = [] Use property.
Object Not recommended. el.config = {...} Use property.
Function Not possible safely. el.onSave = fn Use property or events.

Common Attribute Property Sync Mistakes

  • Treating attributes as booleans instead of strings.
  • Forgetting that boolean attributes are true when present.
  • Using JSON attributes for large complex data.
  • Not reflecting important public state.
  • Reflecting private or high-frequency state unnecessarily.
  • Creating infinite loops between setters and attributeChangedCallback().
  • Not upgrading properties set before custom element definition.
  • Forgetting to update accessibility attributes.
  • Not documenting attribute-property mappings.
  • Re-rendering too much when only one value changed.

Attribute Property Sync Best Practices

  • Use attributes for simple public configuration.
  • Use properties for complex JavaScript values.
  • Reflect public state only when it helps styling, debugging, or accessibility.
  • Use boolean attributes by presence, not by string value.
  • Normalize and validate values in setters.
  • Keep ARIA attributes synchronized with state.
  • Avoid reflecting high-frequency internal state.
  • Upgrade pre-set properties in connectedCallback().
  • Test both HTML attributes and JavaScript properties.
  • Document the public API clearly for framework and plain HTML users.

Pro Tip

A clean Web Component API usually supports both markup and JavaScript: simple values through attributes, complex values through properties, and user-driven changes through custom events.