9 min read

Watching the DOM: Five Ways to Detect Changes in JavaScript

Table of Contents

Cover Image

Understand what changed, choose the right signal, and keep your UI in sync.

Adapted from zhangxinxu’s original article. Code examples are extracted and condensed from the supplied interactive demo; adapted truncation examples retain the original author’s attribution and MIT license.

A paragraph changes. A component disappears. Another script inserts a form into the page.

The DOM has changed—but how should your JavaScript find out?

There are several answers, and they solve different problems. A custom element can respond to its own lifecycle. A MutationObserver can watch an existing subtree. A property setter can react immediately to an assignment you control.

To make these differences concrete, we’ll follow a small example: text that displays two or three lines, depending on a rows value.

Start by Defining What “Changed” Means

Before choosing an API, identify the signal your code needs.

What you need to detectAppropriate approach
A custom component connects, disconnects, or changes an observed attributeCustom element lifecycle callbacks
Attributes, text nodes, or child nodes changeMutationObserver
Application code assigns a specific propertyA property setter
Old code listens for events such as DOMNodeInsertedHistorical mutation events; migrate to MutationObserver
A matching element completes a CSS animationAn animation event, with limited detection guarantees

These mechanisms are not interchangeable. Assigning a JavaScript property, changing an HTML attribute, and replacing a text node are distinct operations.

1. Custom Elements: Let the Component Respond

When you own a component’s implementation, lifecycle callbacks keep its behavior close to the element itself.

The demo defines an <x-ell> element with a rows attribute. Assigning element.rows = 3 updates that attribute, which triggers rendering.

The following excerpt preserves the component’s essential behavior. It uses the demo’s shared .clamp class, whose -webkit-line-clamp value comes from the CSS custom property --rows.

class HTMLEllElement extends HTMLElement {
  static get observedAttributes() { return ['rows']; }

  connectedCallback() {
    if (!this.isConnected) return;
    this._updateRendering();
  }

  attributeChangedCallback(name, oldValue, newValue) {
    this._updateRendering();
  }

  get rows() { return this.getAttribute('rows'); }
  set rows(value) { this.setAttribute('rows', value); }

  _updateRendering() {
    const rows = Math.max(1, parseInt(this.rows, 10) || 2);
    this.classList.add('clamp');
    this.style.setProperty('--rows', rows);
  }
}

customElements.define('x-ell', HTMLEllElement);
document.querySelector('x-ell').rows = 3;

Demo animation

🎮 Try it live: Open the interactive demo to experience this yourself.

The update follows a short path: property assignment → attribute change → lifecycle callback → CSS update.

The getter reads directly from the attribute, avoiding a second copy of the same state. The rendering method defaults invalid values to two lines and prevents values below one.

The demo also includes disconnectedCallback() for removal and adoptedCallback() for movement between documents. Only attributes listed in observedAttributes trigger attributeChangedCallback(). Connection callbacks can run before an element’s children are fully parsed, so initialization that depends on child content needs care. MDN: Using custom elements

This example avoids that parsing dependency: its rendering method updates presentation without inspecting the paragraph’s children.

Use lifecycle callbacks when the reaction belongs to a component you define. They do not automatically observe arbitrary edits throughout its descendants.

2. MutationObserver: Watch an Existing Part of the DOM

Sometimes another library or script owns the markup. You still need to react when it changes.

MutationObserver lets you observe a node and specify which mutations matter. Notifications arrive asynchronously, allowing several synchronous changes to be handled together.

The demo deliberately uses a plain <div> for this example, keeping it independent of the custom element.

Text Edits and Text Replacement Are Different

Two operations can look identical on screen while producing different records:

  • Editing ell.firstChild.data changes an existing text node and produces a characterData mutation.
  • Assigning ell.textContent replaces children and produces a childList mutation.

That distinction matters when configuring observation. Watching only characterData will miss child replacement.

The next excerpt condenses the observer demo’s setup and logging. The existing #observed-ell element has the demo’s .clamp class and an initial rows attribute.

const ell = document.querySelector('#observed-ell');

function render() {
  ell.style.setProperty('--rows', ell.getAttribute('rows'));
}

const observer = new MutationObserver(records => {
  records.forEach(record => {
    console.log(record.type, record.target.nodeName);
  });

  // Render the owner, even when the mutation target is a Text node.
  render();
});

observer.observe(ell, {
  attributes: true,
  attributeFilter: ['rows'],
  attributeOldValue: true,
  characterData: true,
  characterDataOldValue: true,
  childList: true,
  subtree: true
});

render();

ell.setAttribute('rows', '3');
ell.textContent = 'Replacement text creates a childList record.';

Demo animation

🎮 Try it live: Open the interactive demo to experience this yourself.

The callback renders once after inspecting the batch. It does not try to call a rendering method on record.target, because a text mutation targets a Text node rather than the containing element.

The options have separate responsibilities:

OptionPurpose in this demo
attributesObserve attribute changes
attributeFilterLimit attribute observation to rows
attributeOldValueInclude the previous attribute value
characterDataObserve edits to text-node data
characterDataOldValueInclude the previous text value
childListObserve added or removed children
subtreeExtend observation into descendants

Keep the Observer From Triggering Itself

An observer callback can create more mutations.

Here, render() changes the style attribute, but the observer watches only rows. That filter keeps the rendering update from scheduling another callback.

If your rendering function rewrites observed text or children, you need a different safeguard—for example, avoiding writes when the content already matches the intended result.

Observe the smallest useful subtree and request only the records you need.

Understand Records and Cleanup

Each MutationRecord identifies a change through fields such as type, target, and attributeName. Its addedNodes and removedNodes are NodeList objects; when nothing applies, they are empty rather than null.

The demo also exposes two useful controls:

  • takeRecords() returns and clears queued, undelivered records so your code can process them directly.
  • disconnect() stops observation and discards pending notifications. Read pending records first if they matter.

Reconnecting does not replay mutations that happened while observation was stopped. These methods are documented in the MutationObserver API reference.

3. Property Setters: React to an Assignment You Control

You may not need an observer at all.

If your application consistently changes the line count through ell.rows, a setter can update the attribute and render immediately.

This excerpt comes from the demo’s property section. Its target is a separate plain element using the shared .clamp class.

const ell = document.querySelector('#property-ell');

function render() {
  const rows = Math.max(1, parseInt(ell.rows, 10) || 2);
  ell.style.setProperty('--rows', rows);
}

Object.defineProperty(ell, 'rows', {
  enumerable: true,
  configurable: true,
  get() {
    return this.getAttribute('rows');
  },
  set(value) {
    this.setAttribute('rows', value);
    render();
  }
});

ell.rows = 3;                  // Calls the setter and renders.
ell.setAttribute('rows', '4'); // Changes the attribute only.

Demo animation

🎮 Try it live: Open the interactive demo to experience this yourself.

The last two lines reveal the boundary of this technique.

Assigning ell.rows invokes the setter synchronously. Calling setAttribute() directly bypasses it. The getter then reports "4", while the last rendered clamp remains three lines.

That behavior makes a setter useful when you control the update path. It also makes a setter insufficient when unrelated code can modify attributes directly.

Accessor descriptors use get and set. Do not add writable to them; the original article’s writeable spelling is not a recognized descriptor option.

4. Mutation Events: Historical Context for Legacy Code

Older implementations used events such as DOMNodeInserted, DOMNodeRemoved, and DOMSubtreeModified.

Their appeal was straightforward: attach an event listener and handle the change. Their synchronous behavior, however, introduced performance and reentrancy problems. Chrome disabled mutation events by default starting in version 127. They should not underpin new implementations. Chrome 127 release notes

The supplied demo handles this history explicitly. It manually dispatches a custom demo:before-remove event before calling remove(), then compares that synchronous log with a real observer notification.

That custom event is a simulation, not native mutation detection.

Before removal, the node still has its parent. In the demo’s observer callback, it no longer does. More generally, observer callbacks see the DOM’s current state: a removed node might have been reinserted before delivery. Use the mutation record to identify the affected parent and removed nodes rather than assuming their present relationships describe the original operation.

5. CSS Animation: A Signal With Narrow Boundaries

The demo’s final technique applies a short animation to newly inserted elements matching .detected-node.

A delegated animationend listener checks the animation name, then logs the matching element. The animation changes a custom property, so it creates no visible movement.

This can provide a selector-based signal, but it does not provide mutation records.

It also has important limits:

  • Removing an element before completion can prevent animationend from firing.
  • Clearing nodes does not generate a removal notification through this mechanism.
  • Restarting the animation can generate another event without a new insertion.

An animation event tells you about an animation. Any conclusion about DOM changes depends on how your application applies that animation.

Choose the Mechanism That Matches Your Update Path

The line-clamp demo reaches a similar visual result through different contracts.

A custom element owns its attribute-driven behavior. A MutationObserver reacts to changes made within an observed subtree. A setter reacts to a particular JavaScript assignment. Legacy mutation events explain older code, while CSS animations offer a specialized indirect signal.

Before wiring up a listener, ask: Who makes the change, what exactly changes, and when does my code need to respond?

Those answers determine both the API you need and the updates it might otherwise miss.


Try It Yourself

Want to see these concepts in action? I’ve created an interactive demo where you can experiment with the code and see real-time results.

View the Live Demo

Explore more demos from my previous articles in the Demo Gallery.

Happy coding!