Monitoring DOM Changes with JavaScript

Track real-time DOM updates with MutationObserver for instant change detection in dynamic web applications. Learn more

Ceyhun Enki Aksan
Ceyhun Enki Aksan Entrepreneur, Maker

In the article titled “Logging Visual Clicks with Google Analytics”, I discussed how visual clicks within a gallery created using the WordPress NextGEN Gallery plugin can be tracked.

As a note, the NextGEN Gallery plugin handles pagination within a gallery in two ways: via GET and via AJAX1. In the relevant example, I implemented pagination based on URL changes. With AJAX, the JS maintains the image URLs from the previous page as values. As a result, even if the displayed content changes, the data sent upon clicks remains unchanged. In this article, I will attempt to explain how DOM modifications can be monitored in such a situation.

Monitoring DOM Changes

In my previous article, I attempted to provide a basic introduction to DOM operations through JavaScript Basic DOM Operations. As mentioned in the article, when selecting and listing elements using JavaScript, the returned values are static. Consequently, real-time changes will not be reflected in the list. However, we can update the list content based on changes by partially or fully monitoring DOM modifications through JavaScript.

First, let’s create a simple HAML page page that we will use for our example operations.

5 %html %head %meta{:content => "text/html; charset=UTF-8", "http-equiv" => "Content-Type"} %meta{:charset => "utf-8"} %meta{:content => "width=device-width, initial-scale=1", :name => "viewport"} %title Hello Bulma!

title Hello Bulma!
    %link{:href => "https://cdn.jsdelivr.net/npm/bulma@0.9.1/css/bulma.min.css", :rel => "stylesheet"}
  %body
    %section.section
      .container.is-max-desktop
        .columns.is-vcentered.is-centered.is-variable.is-6
          .column.is-8-desktop.is-8-tablet.is-12-mobile
            .box
              %p#changeme.content
                The Caterpillar and Alice looked at each other for some time in silence: at last the Caterpillar took the hookah out of its mouth, and addressed her in a languid, sleepy voice.
            .box
              .field.has-addons
                .control.is-expanded
                  %input.input{:placeholder => "Text input", :type => "text"}
                .control
                  %button#addItem.button.is-link Add to list
              %ul#myList
                %li.notification.is-link.is-light No item!
            %button#disconnect.button.is-warning Disconnect
    :javascript
      -#...

We have an HTML interface for a to-do list composed of an input field and a list area. Our goal is to add the text entered in the input field to the corresponding list. At this stage, two events will occur: the “No item!” message will be removed, and the value entered in the input field will be added to the list. Normally, we can associate event handlers with the respective event definitions to observe these actions.

document
  .querySelector('#addItem')
  .addEventListener('click', e => {
  //...
});

The above code snippet is listening for click events on the element in question, and the specified actions are processed upon clicking. However, we might be left with the need to observe DOM changes without modifying the code itself. Several approaches are available for this purpose.

DOM Level 3 - UI Events was one of these options. However, development was halted due to it being deprecated23. In the rest of the article, I’ll discuss two approaches I’m aware of: animationstart and MutationObserver.

CSS Animations

The solution related to MutationObserver usage caught my attention and seemed quite interesting/useful, so I included it in the article. This solution is by David Walsh. On the other hand, I also found Daniel Buchner’s SelectorListener example, from Daniel Buchner, to be highly inspiring.

The fundamental principle behind this solution is CSS animations. As you know, CSS animations enable transitions between different CSS style configurations. This process occurs through two components: a style definition that declares the animation, and a sequence of keyframes (keyframe) that specify the start and end states of the animation, as well as any intermediate transition points45.

CSS Animation
CSS Animation

Let’s first create a simple example for CSS animations before linking them to DOM events.

p#changeme {
  animation-duration: 3s;
  animation-name: colorize;
  animation-iteration-count: 3;
}
@keyframes colorize {
  from {
    color: blue;
  }
  to {
    color: red;
  }
}

The related animation transition takes place through keyframe definitions specified by @keyframes, enabling the execution of animation features. JavaScript allows tracking the start, flow, and end of these keyframes via the animationstart, animationiteration, and animationend events, respectively. In this way, it becomes possible to handle the differences associated with animation operations. Detecting DOM changes is also one of these methods5.

The above example was based on the p element. Thus, let’s proceed with the same example and observe the animation flow.

const element = document.getElementById("changeme");
element.addEventListener("animationstart", e => console.log(e), false);
element.addEventListener("animationend", e => console.log.log(e), false);
element.addEventListener("animationiteration", e => console.log(e), false);

The above code snippet will relay the @keyframes definitions to the console.

Infinite CSS Animation
Infinite CSS Animation

As shown, due to the animation-iteration-count: infinite definition, the animationiteration event continuously repeats after the animationstart, and it is impossible to reach the animationend event without external intervention. Now, let’s set a limit of three iterations using animation-iteration-count: 3.

Infinite CSS Animation
Infinite CSS Animation

As shown, after the animationstart, the animationiteration event repeats n-1 times, and then the animation concludes with the animationend event.

Now let’s see how we can apply this approach to tracking DOM changes.

As David mentioned in his article, the hack6 essentially involves closely maintaining the transition between animation frames and merely observing the start of the animation.

Therefore, let’s apply this approach to the list above and monitor the newly added item elements.

const node = document.createElement('li');
const textMode = document.createTextNode('A new item!');
node.appendChild(textMode);
node.setAttribute('class', 'notification is-link');
list.appendChild(node);

list.addEventListener("animationstart", e => {
  if (e.animationName == "nodeInserted") console.warn("Another node has been inserted! ", e, e.target);
}, false);
DOM Monitoring - CSS Animation
DOM Monitoring - CSS Animation

Yes, it is possible to monitor DOM changes related to CSS animations in this way. However, this approach will only detect changes related to a single node; it may not trigger for changes in attributes. For such scenarios, the MutationObserver option can be considered.

MutationObserver API

MutationObserver is a built-in object that observes a DOM element and triggers a callback when changes occur, serving as an alternative to the deprecated Mutation events and supported in modern web browsers7.

const observer = new MutationObserver(callback);

Using MutationObserver, we can be notified when a node changes (insertion, deletion) and/or when its properties or content are modified8 9. To monitor DOM changes, we first need to create a MutationObserver instance (example). If the observation is valid for a specific duration, the instance can be stopped at the end. The first argument of the function created with the instance is a collection of mutations that occurred within a single group. Each mutation provides information about the changes that took place10.

const obsInstance = new MutationObserver(function(mutations) {
  mutations.forEach(function(mutation) {
    console.log(mutation);
  });
});

Together with the creation of an instance, we can now access the relevant object using the observe, disconnect, and takeRecords methods. The observe method specifies which node and what types of changes to monitor (observe); it is defined as mutationObserver.observe(target, options).

obsInstance.observe(document.documentElement, {
  attributes: true,
  characterData: true,
  childList: true,
  subtree: true,
  attributeOldValue: true,
  characterDataOldValue: true
});

It is generally recommended to directly specify the relevant node as the target, and to ensure that the query does not return null prior to the operation if a querySelection* is used. The above options (option) definitions correspond to the following3:

childList
: Monitors modifications to the child nodes of the specified node.

subtree
: Monitors all descendants (descendants) within the specified node.

attributes
: Monitors the attributes of the specified node.

attributeFilter
: A list definition specifying which attributes to monitor.

characterData
: Specifies whether the node’s data should be observed.

Now we can adjust our example to include these details.

const item = {
  add: (item, val, clss = 'is-link') => {
    if(val){
      const node = document.createElement('li'),
            textMode = document.createTextNode(val);
      node.appendChild(textMode);
      node.classList.add('notification', clss);
      list.appendChild(node);
    }
  },
  remove: (clss) => {
    const found = [...list.children].find(e => e.classList.contains(clss));
    if(found) found.remove(this);
  }
}
    
const list = document.getElementById('myList'),
      addItemButton = document.querySelector('#addItem'),
      disconnectButton = document.querySelector('#disconnect'),
      txtInput = document.querySelector('.input');

addItemButton.addEventListener('click', () => {
  if (txtInput.value) {
    item.remove('is-light');
    item.add(list, txtInput.value);
    txtInput.value = '';
    txtInput.focus();
  }
});

if(list){
  MutationObserver = window.MutationObserver || window.WebKitMutationObserver;

  const observer = new MutationObserver((mutations, observer) => {
    mutations.forEach(e => {
      console.log(e);
      // console.log(e.type);
      // console.log(e.target);
      // console.log(e.addedNodes)
    });
  });

  observer.observe(list, {
    childList: true,
    subtree: true,
  });
      
  disconnectButton.addEventListener('click', () => {
    observer.disconnect();
    item.add(list, 'Disconnected');
  });
}

By observing the Console section, you can see that each time a new entry is added to the list, the MutationRecord object provides us with detailed information such as the content of added or removed nodes, the relationship between the affected node and other related nodes, and more3 11 8. With this information, you can now perform your operations. After completing your operations, if you click the Disconnect button, you will be able to see that subsequent list changes will no longer be monitored.

*[DOM]: Document Object Model
*[JS]: JavaScript
*[HTML]: Hypertext Markup Language
*[AJAX]: Asynchronous JavaScript and XML

Footnotes

  1. NextGEN Basic Thumbnail Gallery. Imagely
  2. UI Events. W3C Working Draft, 04 August 2016
  3. Mutation observer. javascript.info 2 3
  4. CSS Animations. MDN web docs
  5. Using CSS animations. MDN web docs 2
  6. David Walsh. (2012). Detect DOM Node Insertions with JavaScript and CSS Animations
  7. MutationObserver. MDN web docs
  8. Mutation observers. DOM Living Standard — Last Updated 24 December 2020 2
  9. Is there a JavaScript / jQuery DOM change listener? StackOverflow
  10. Alexander Zlatkov. (2018). How JavaScript works: tracking changes in the DOM using MutationObserver
  11. Emre Erkoca. (2020). MutationObserver and Event Usage