In complex web apps, DOM changes can be frequent. As a result, there are instances where your app might need to respond to a specific change to the DOM.
For some time, the accepted way to look for changes to the DOM was by means of a feature called Mutation Events, which is now deprecated. The W3C-approved replacement for Mutation Events is the MutationObserver API, which is what I’ll be discussing in detail in this article.
A number of older articles and references discuss why the old feature was replaced, so I won’t go into detail on that here (besides the fact that I wouldn’t be able to do it justice). The
MutationObserver API has near complete browser support, so we can use it safely in most — if not all — projects, should the need arise.
Basic Syntax For A MutationObserver
MutationObserver can be used in a number of different ways, which I’ll cover in detail in the rest of this article, but the basic syntax for a
MutationObserver looks like this:
let observer = new MutationObserver(callback); function callback (mutations) // do something here observer.observe(targetNode, observerOptions);
The first line creates a new
MutationObserver using the
MutationObserver() constructor. The argument passed into the constructor is a callback function that will be called on each DOM change that qualifies.
The way to determine what qualifies for a particular observer is by means of the final line in the above code. On that line, I’m using the
observe() method of the
MutationObserver to begin observing. You can compare this to something like
addEventListener(). As soon as you attach a listener, the page will ‘listen’ for the specified event. Similarly, when you start observing, the page will begin ‘observing’ for the specified
observe() method takes two arguments: The target, which should be the node or node tree on which to observe for changes; and an options object, which is a
MutationObserverInit object that allows you to define the configuration for the observer.
The final key basic feature of a
MutationObserver is the
disconnect() method. This allows you to stop observing for the specified changes, and it looks like this:
Options To Configure A MutationObserver
As mentioned, the
observe() method of a
MutationObserver requires a second argument that specifies the options to describe the
MutationObserver. Here’s how the options object would look with all possible property/value pairs included:
let options = childList: true, attributes: true, characterData: false, subtree: false, attributeFilter: ['one', 'two'], attributeOldValue: false, characterDataOldValue: false ;
When setting up the
MutationObserver options, it’s not necessary to include all these lines. I’m including these simply for reference purposes, so you can see what options are available and what types of values they can take. As you can see, all except one are Boolean.
In order for a
MutationObserver to work, at least one of
characterData needs to be set to
true, otherwise an error will be thrown. The other four properties work in conjunction with one of those three (more on this later).
So far I’ve merely glossed over the syntax to give you an overview. The best way to consider how each of these features works is by providing code examples and live demos that incorporate the different options. So that’s what I’ll do for the rest of this article.
Observing Changes To Child Elements Using childList
The first and simplest
MutationObserver you can initiate is one that looks for child nodes of a specified node (usually an element) to be added or removed. For my example, I’m going to create an unordered list in my HTML, and I want to know whenever a child node is added or removed from this list element.
The HTML for the list looks like this:
<ul id="myList" class="list"> <li>Apples</li> <li>Oranges</li> <li>Bananas</li> <li class="child">Peaches</li> </ul>
MutationObserver includes the following:
let mList = document.getElementById('myList'), options = childList: true , observer = new MutationObserver(mCallback); function mCallback(mutations) for (let mutation of mutations) if (mutation.type === 'childList') console.log('Mutation Detected: A child node has been added or removed.'); observer.observe(mList, options);
This is only part of the code. For brevity, I’m showing the most important sections that deal with the
MutationObserver API itself.
Notice how I’m looping through the
mutations argument, which is a
MutationRecord object that has a number of different properties. In this case, I’m reading the
type property and logging a message indicating that the browser has detected a mutation that qualifies. Also, notice how I’m passing the
mList element (a reference to my HTML list) as the targeted element (i.e. the element on which I want to observe for changes).
Use the buttons to start and stop the
MutationObserver. The log messages help clarify what’s happening. Comments in the code also provide some explanation.
Note a few important points here:
- The callback function (which I’ve named
mCallback, to illustrate that you can name it whatever you want) will fire each time a successful mutation is detected and after the
observe()method is executed.
- In my example, the only ‘type’ of mutation that qualifies is
childList, so it makes sense to look for this one when looping through the MutationRecord. Looking for any other type in this instance would do nothing (the other types will be used in subsequent demos).
childList, I can add or remove a text node from the targeted element and this too would qualify. So it doesn’t have to be an element that’s added or removed.
- In this example, only immediate child nodes will qualify. Later in the article, I’ll show you how this can apply to all child nodes, grandchildren, and so on.
Observing For Changes To An Element’s Attributes
Another common type of mutation that you might want to track is when an attribute on a specified element changes. In the next interactive demo, I’m going to observe for changes to attributes on a paragraph element.
let mPar = document.getElementById('myParagraph'), options = attributes: true , observer = new MutationObserver(mCallback); function mCallback (mutations) for (let mutation of mutations) if (mutation.type === 'attributes') // Do something here... observer.observe(mPar, options);
Again, I’ve abbreviated the code for clarity, but the important parts are:
optionsobject is using the
attributesproperty, set to
trueto tell the
MutationObserverthat I want to look for changes to the targeted element’s attributes.
- The mutation type I’m testing for in my loop is
attributes, the only one that qualifies in this case.
- I’m also using the
attributeNameproperty of the
mutationobject, which allows me to find out which attribute was changed.
- When I trigger the observer, I’m passing in the paragraph element by reference, along with the options.
In this example, a button is used to toggle a class name on the targeted HTML element. The callback function in the mutation observer is triggered every time the class is added or removed.
Observing For Character Data Changes
Another change you might want to look for in your app is mutations to character data; that is, changes to a specific text node. This is done by setting the
characterData property to
true in the
options object. Here’s the code:
let options = characterData: true , observer = new MutationObserver(mCallback); function mCallback(mutations) for (let mutation of mutations) if (mutation.type === 'characterData') // Do something here...
Notice again the
type being looked for in the callback function is
In this example, I’m looking for changes to a specific text node, which I target via
element.childNodes. This is a little hacky but it will do for this example. The text is user-editable via the
contenteditable attribute on a paragraph element.
Challenges When Observing For Character Data Changes
If you’ve fiddled around with
contenteditable, then you might be aware that there are keyboard shortcuts that allow for rich text editing. For example, CTRL-B makes text bold, CTRL-I makes text italic, and so forth. This will break up the text node into multiple text nodes, so you’ll notice the
MutationObserver will stop responding unless you edit the text that’s still considered part of the original node.
I should also point out that if you delete all the text, the
MutationObserver will no longer trigger the callback. I’m assuming this happens because once the text node disappears, the target element is no longer in existence. To combat this, my demo stops observing when the text is removed, although things do get a little sticky when you use rich text shortcuts.
But don’t worry, later in this article, I’ll discuss a better way to use the
characterData option without having to deal with as many of these quirks.
Observing For Changes To Specified Attributes
Earlier I showed you how to observe for changes to attributes on a specified element. In that case, although the demo triggers a class name change, I could have changed any attribute on the specified element. But what if I want to observe changes to one or more specific attributes while ignoring the others?
I can do that using the optional
attributeFilter property in the
option object. Here’s an example:
let options = attributes: true, attributeFilter: ['hidden', 'contenteditable', 'data-par'] , observer = new MutationObserver(mCallback); function mCallback (mutations) for (let mutation of mutations) if (mutation.type === 'attributes') // Do something here...
As shown above, the
attributeFilter property accepts an array of specific attributes that I want to monitor. In this example, the
MutationObserver will trigger the callback each time one or more of the
data-par attributes is modified.
Again I’m targeting a specific paragraph element. Notice the select drop down that chooses which attribute will be changed. The
draggable attribute is the only one that won’t qualify since I didn’t specify that one in my options.
Notice in the code that I’m again using the
attributeName property of the
MutationRecord object to log which attribute was changed. And of course, as with the other demos, the
MutationObserver won’t start monitoring for changes until the “start” button is clicked.
One other thing I should point out here is that I don’t need to set the
attributes value to
true in this case; it’s implied due to
attributesFilter being set to true. That’s why my options object could look as follows, and it would work the same:
let options = attributeFilter: ['hidden', 'contenteditable', 'data-par']
On the other hand, if I explicitly set
false along with an
attributeFilter array, it wouldn’t work because the
false value would take precedence and the filter option would be ignored.
Observing For Changes To Nodes And Their Sub-Tree
So far when setting up each
MutationObserver, I’ve only been dealing with the targeted element itself and, in the case of
childList, the element’s immediate children. But there certainly could be a case where I might want to observe for changes to one of the following:
- An element and all its child elements;
- One or more attributes on an element and on its child elements;
- All text nodes inside an element.
All of the above can be achieved using the
subtree property of the options object.
childList With subtree
First, let’s look for changes to an element’s child nodes, even if they’re not immediate children. I can alter my options object to look like this:
options = childList: true, subtree: true
Everything else in the code is more or less the same as the previous
childList example, along with some extra markup and buttons.
Here there are two lists, one nested inside the other. When the
MutationObserver is started, the callback will trigger for changes to either list. But if I were to change the
subtree property back to
false (the default when it’s not present), the callback would not execute when the nested list is modified.
Attributes With subtree
Here’s another example, this time using
attributeFilter. This allows me to observe for changes to attributes not only on the target element but also on the attributes of any child elements of the target element:
options = attributes: true, attributeFilter: ['hidden', 'contenteditable', 'data-par'], subtree: true
This is similar to the previous attributes demo, but this time I’ve set up two different select elements. The first one modifies attributes on the targeted paragraph element while the other one modifies attributes on a child element inside the paragraph.
Again, if you were to set the
subtree option back to
false (or remove it), the second toggle button would not trigger the
MutationObserver callback. And, of course, I could omit
attributeFilter altogether, and the
MutationObserver would look for changes to any attributes in the subtree rather than the specified ones.
characterData With subtree
Remember in the earlier
characterData demo, there were some problems with the targeted node disappearing and then the
MutationObserver no longer working. While there are ways to get around that, it’s easier to target an element directly rather than a text node, then use the
subtree property to specify that I want all the character data inside that element, no matter how deeply nested it is, to trigger the
My options in this case would look like this:
options = characterData: true, subtree: true
After you start the observer, try using CTRL-B and CTRL-I to format the editable text. You’ll notice this works much more effectively than the previous
characterData example. In this case, the broken up child nodes don’t affect the observer because we’re observing all nodes inside the targeted node, instead of a single text node.
Recording Old Values
Often when observing for changes to the DOM, you’ll want to take note of the old values and possibly store them or use them elsewhere. This can be done using a few different properties in the
First, let’s try logging out the old attribute value after it’s changed. Here’s how my options will look along with my callback:
options = attributes: true, attributeOldValue: true function mCallback (mutations) for (let mutation of mutations) if (mutation.type === 'attributes') // Do something here...
Notice the use of the
oldValue properties of the
MutationRecord object. Try the demo by entering different values in the text field. Notice how the log updates to reflect the previous value that was stored.
Similarly, here’s how my options would look if I want to log old character data:
options = characterData: true, subtree: true, characterDataOldValue: true
Notice the log messages indicate the previous value. Things do get a little wonky when you add HTML via rich text commands to the mix. I’m not sure what the correct behavior is supposed to be in that case but it is more straightforward if the only thing inside the element is a single text node.
Intercepting Mutations Using takeRecords()
Another method of the
MutationObserver object that I haven’t mentioned yet is
takeRecords(). This method allows you to more or less intercept the mutations that are detected before they are processed by the callback function.
I can use this feature using a line like this:
let myRecords = observer.takeRecords();
This stores a list of the DOM changes in the specified variable. In my demo, I’m executing this command as soon as the button that modifies the DOM is clicked. Notice that the start and add/remove buttons don’t log anything. This is because, as mentioned, I’m intercepting the DOM changes before they are processed by the callback.
Notice, however, what I’m doing in the event listener that stops the observer:
btnStop.addEventListener('click', function () observer.disconnect(); if (myRecords) console.log(`$myRecords.target was changed using the $myRecords.type option.`); , false);
As you can see, after stopping the observer using
observer.disconnect(), I’m accessing the mutation record that was intercepted and I’m logging the target element as well as the type of mutation that was recorded. If I had been observing for multiple types of changes then the stored record would have more than one item in it, each with its own type.
When a mutation record is intercepted in this way by calling
takeRecords(), the queue of mutations that would normally be sent to the callback function is emptied. So if for some reason you need to intercept these records before they’re processed,
takeRecords() would come in handy.
Observing For Multiple Changes Using A Single Observer
Note that if I’m looking for mutations on two different nodes on the page, I can do so using the same observer. This means after I call the constructor, I can execute the
observe() method for as many elements as I want.
Thus, after this line:
observer = new MutationObserver(mCallback);
I can then have multiple
observe() calls with different elements as the first argument:
observer.observe(mList, options); observer.observe(mList2, options);
Start the observer, then try the add/remove buttons for both lists. The only catch here is that if you hit one of the “stop” buttons, the observer will stop observing for both lists, not just the one it’s targeting.
Moving A Node Tree That’s Being Observed
One last thing I’ll point out is that a
MutationObserver will continue to observe for changes to a specified node even after that node has been removed from its parent element.
For example, try out the following demo:
This is another example that uses
childList to monitor for changes to the child elements of a target element. Notice the button that disconnects the sub-list, which is the one being observed. Click the “Start…” button, then click the “Move…” button to move the nested list. Even after the list is removed from its parent, the
MutationObserver continues to observe for the specified changes. Not a major surprise that this happens, but it’s something to keep in mind.
That covers just about all the primary features of the
MutationObserver API. I hope this deep dive has been useful for you to get familiar with this standard. As mentioned, browser support is strong and you can read more about this API on MDN’s pages.
I’ve put all the demos for this article into
a CodePen collection, should you want to have an easy place to mess around with the demos.