Avoiding Infinite Attribute Loops: Breaking the Reflection Cycle

Reflection is a two-way mirror. A property setter writes an attribute so the state is visible in the DOM; attributeChangedCallback writes the property so external attribute changes reach the component. Wire both without a guard and each write triggers the other: setter → attribute → callback → setter, forever. In the best case the browser’s own de-duplication masks it and the component merely does twice the work it should. In the worst case the tab locks up with RangeError: Maximum call stack size exceeded.

The correct pattern is not “add a guard flag” — that is the symptom fix, and it breaks the moment two attributes participate. The correct pattern is to give the state exactly one home and make the other side a projection of it. This deep dive belongs to Attribute Reflection & Property Sync inside Core Architecture & Lifecycle Management.

Problem Statement: The Setter That Calls Itself

class RangeSlider extends HTMLElement {
  static observedAttributes = ['value', 'step'];

  #value = 0;

  get value() { return this.#value; }

  set value(next) {
    // Snap to the step BEFORE storing — a transform, which is where the loop bites.
    const step = Number(this.getAttribute('step') ?? 1);
    this.#value = Math.round(next / step) * step;
    this.setAttribute('value', String(this.#value));   // writes the attribute
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'value') {
      this.value = Number(newValue);                    // writes the property
    }
  }
}
customElements.define('range-slider', RangeSlider);
const slider = document.querySelector('range-slider');
slider.setAttribute('step', '5');
slider.value = 7;
// RangeError: Maximum call stack size exceeded

Setting value = 7 with a step of 5 snaps to 5 and writes value="5". The callback fires, sets value = 5, which snaps to 5 and writes value="5" — and here the browser’s own guard would normally stop the cycle, because setting an attribute to its current value still fires the callback but the round trip has reached a fixed point. It does not stop, because the first write never reached a fixed point: 7 snapped to 5, the callback set 5, which the setter wrote again, and with a non-idempotent transform in the chain the values keep changing just enough to keep going.

The reflection cycle and where it fails to converge A property setter writes an attribute, the attribute callback writes the property, and a transform inside the setter means the values never reach a fixed point. Each side writes the other, and the transform keeps the values moving property setter snaps, then setAttribute attributeChangedCallback parses, then assigns setAttribute this.value = Converges when the write is idempotent value 5 → attribute "5" → value 5 second write equals the first the browser stops re-firing Diverges when a transform sits in the loop clamping, snapping, rounding, formatting every pass produces a new value stack overflow, or silent double work

Root-Cause Analysis

The HTML Standard is unambiguous about the trigger: attributeChangedCallback is enqueued whenever an observed attribute is set, changed, or removed — including when it is set to the value it already has. There is no “only if different” filter at the platform level. setAttribute('value', '5') on an element whose attribute is already "5" still enqueues the reaction.

So the cycle exists by construction in any component that reflects both ways. What decides whether it terminates is whether the round trip is a fixed point: does property → attribute → property produce the same property value it started with? When it does, the second pass writes an identical attribute, the callback assigns an identical property, and although the reactions still fire, nothing changes and the recursion bottoms out at a depth of two.

When it does not, the loop is genuinely infinite. Three transforms break the fixed point and all three are common:

Debugging Pitfall — the guard flag that only appears to work. A #reflecting boolean set around the setAttribute call stops the immediate recursion and creates a subtler bug: an external attribute change that arrives while the flag is set is ignored entirely. Frameworks that batch attribute writes, and users editing attributes in DevTools, both hit this. The guard trades a loud crash for silent lost updates, which is a worse trade than it looks.

The alternative framing that dissolves the problem: decide which side owns the value. Either the attribute is the source of truth and the property is a thin accessor over it, or the property is the source of truth and the attribute is a write-only projection. Once one side owns the state, there is no cycle to break, because the round trip has been replaced by a one-way flow.

Two one-way designs that cannot loop Either the attribute owns the value and the property reads through it, or the property owns the value and the attribute is a write-only projection guarded by an equality check. A. The attribute owns the value get value() → parse(getAttribute) set value() → setAttribute only callback re-renders; never assigns no stored state, so nothing to desynchronise B. The property owns the value set value() → normalise, store project: write only if different callback assigns only if different equality checks on both sides, no flags

Production-Safe Fix

The version below keeps the property as the source of truth, normalises once, and makes both directions no-ops when the value has not actually changed. No guard flag appears anywhere.

class RangeSlider extends HTMLElement {
  static observedAttributes = ['value', 'step', 'min', 'max'];

  #value = 0;

  /** Single normalisation point. Idempotent: normalise(normalise(x)) === normalise(x). */
  #normalise(raw) {
    const step = Number(this.getAttribute('step')) || 1;
    const min = Number(this.getAttribute('min')) || 0;
    const max = Number(this.getAttribute('max') ?? 100);
    const parsed = Number.isFinite(Number(raw)) ? Number(raw) : min;
    const snapped = Math.round((parsed - min) / step) * step + min;
    return Math.min(max, Math.max(min, snapped));
  }

  get value() { return this.#value; }

  set value(next) {
    const normalised = this.#normalise(next);
    if (normalised === this.#value) return;      // nothing changed: stop here
    this.#value = normalised;
    this.#project();
    this.#emit();
  }

  /** Write-only projection of the owned state into the DOM. */
  #project() {
    const asText = String(this.#value);
    // Equality check: setAttribute always re-fires the reaction, so compare first.
    if (this.getAttribute('value') !== asText) this.setAttribute('value', asText);
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue === newValue) return;            // no-op writes stop immediately

    if (name === 'value') {
      const normalised = this.#normalise(newValue);
      if (normalised === this.#value) {
        // The attribute holds an un-normalised form of the value we already have.
        // Re-project rather than re-assign, so the DOM converges without a cycle.
        this.#project();
        return;
      }
      this.#value = normalised;
      this.#project();
      this.#emit();
      return;
    }

    // Constraint attributes changed: re-normalise the value we already hold.
    const renormalised = this.#normalise(this.#value);
    if (renormalised !== this.#value) {
      this.#value = renormalised;
      this.#project();
      this.#emit();
    }
  }

  #emit() {
    this.dispatchEvent(new CustomEvent('value-change', {
      detail: { value: this.#value }, bubbles: true, composed: true
    }));
  }
}
customElements.define('range-slider', RangeSlider);

Four properties make this terminate, and each is worth naming because dropping any one reintroduces the loop.

Normalisation is idempotent. #normalise clamps and snaps to a lattice, so applying it twice gives the same answer as applying it once. That alone guarantees a fixed point exists.

Both directions short-circuit on equality. The setter returns early when nothing changed; the callback returns early when oldValue === newValue; the projection compares before writing. Each check turns a would-be second pass into a no-op.

Constraint changes re-normalise rather than re-assign. When step changes, the callback recomputes from the stored value rather than re-entering the setter, so the coupled-attribute case has no re-entry path at all.

The un-normalised attribute case re-projects. Someone writing value="7" in DevTools with a step of 5 gets the attribute corrected to "5" — one extra write, then a fixed point — rather than a fight between the DOM and the component.

Verification

const slider = document.querySelector('range-slider');
slider.setAttribute('min', '0');
slider.setAttribute('max', '100');
slider.setAttribute('step', '5');

// 1. The transform terminates.
slider.value = 7;
console.assert(slider.value === 5, 'snapped to the step');
console.assert(slider.getAttribute('value') === '5', 'attribute reflects the normalised value');

// 2. An un-normalised external write converges in one correction.
let changes = 0;
slider.addEventListener('value-change', () => { changes += 1; });
slider.setAttribute('value', '12');
console.assert(slider.value === 10, 'external write normalised');
console.assert(slider.getAttribute('value') === '10', 'attribute corrected');
console.assert(changes === 1, 'exactly one change event, not a cascade');

// 3. Idempotence: the same write twice does nothing the second time.
changes = 0;
slider.value = 10;
slider.value = 10;
console.assert(changes === 0, 'no event for a no-op assignment');

// 4. A constraint change re-normalises without re-entering the setter.
slider.setAttribute('step', '25');
console.assert(slider.value === 0 || slider.value % 25 === 0, 're-normalised to the new lattice');

// 5. The decisive check: no runaway recursion under a hostile sequence.
for (let i = 0; i < 1000; i += 1) slider.value = Math.random() * 100;
console.assert(slider.value % 5 === 0 || slider.value % 25 === 0, 'still on the lattice');

The most useful instrumentation during development is a depth counter: increment a module-level number at the top of attributeChangedCallback and decrement at the bottom, logging when it exceeds two. A correctly designed reflection never exceeds two, so any higher reading names the exact attribute pair that is cycling. In DevTools, editing the attribute directly in the Elements panel exercises the external-write path that a guard flag would have swallowed.

When to Use vs When to Avoid

State Design
Simple string or enum, no transform Attribute owns it; property is a thin accessor
Boolean flag Attribute owns it; property maps presence to true
Number needing clamping or snapping Property owns it; attribute is a projection with an equality check
Value the consumer sets before upgrade Property owns it, with property-upgrade handling at connect
Rich value (object, array, function) Property only — never reflect it to an attribute
Transient UI state (loading, dragging) Neither — use a custom state, which cannot loop

The last two rows remove the problem rather than solving it. An object has no faithful attribute representation, so reflecting it is wrong regardless of loops. And transient state belongs in a custom state set, which is invisible to the DOM and therefore has no reflection cycle to manage.

Reaction depth for a converging design versus a cycling one A correct reflection reaches a fixed point at depth two, while a cycling one climbs without bound until the call stack is exhausted. Instrument the depth: a correct component never exceeds two 0 1 2 3 4+ successive reaction passes for one assignment equality checks stop pass three a transform keeps producing new values fixed point reached RangeError ahead

Frequently Asked Questions

Does setting an attribute to its current value fire the callback?

Yes. The specification enqueues the reaction on every set, change, or removal, with no equality filter, which is why components must compare oldValue and newValue themselves.

Is a guard flag ever acceptable?

It stops the recursion and silently drops external attribute changes that arrive while it is set — including writes from frameworks and from DevTools. Prefer equality checks on both sides and an idempotent normalisation, which have no such blind spot.

Which side should own the value?

The attribute, when the value is a simple string, enum, or boolean with no transform. The property, when parsing, clamping, or snapping is involved — then the attribute becomes a write-only projection guarded by an equality check.

Should I reflect object-valued properties?

No. Attributes are strings, so an object round-trips through [object Object] or a JSON string that no consumer can reliably author. Keep rich values property-only and expose a simple attribute for the part consumers need in markup.