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.
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:
- Clamping and snapping.
Math.round(next / step) * stepmaps 7 to 5, and the callback then feeds 5 back in — fine by itself, but combined with any asymmetry in parsing it keeps moving. - Formatting. A setter that writes
String(value)while the getter parses withparseFloatturns1.50into1.5on every pass. - Coupled attributes. A setter for
valuethat also normalisesminandmaxtriggers their callbacks, which re-enter thevaluesetter with a different starting point. A single boolean guard cannot express this, because the re-entry is through a different attribute.
#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.
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.
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.
Related
- Attribute Reflection & Property Sync — the parent topic on the two-way contract.
- Core Architecture & Lifecycle Management — the section this deep dive belongs to.
- Syncing HTML Attributes to JavaScript Properties — the base reflection pattern this refines.
- Custom States & State-Driven Styling — the channel for state that should not be reflected at all.
- Lifecycle Callbacks Deep Dive — when
attributeChangedCallbackruns relative to construction.