by

Promising a mutation: Using Mutation Observer and Promises together

Sometimes you may run into a challenging situation: You want some JavaScript to execute, but only if some other element has changed. At work, we’ve recently run into a situation with this kind of challenge. We’re using a library on videos, but how can we make sure that our JavaScript only runs after the video library runs? Well, we do it by mixing two really cool technologies: MutationObserver and Promise.

Learning the MutationObserver

So what is a MutationObserver ?
It’s an interface that allows you to observe a mutation in the DOM1. It’s a way to find out if elements have changed. We can discover if the element has gotten new attributes, or if it’s had child elements added or removed. So that makes this super handy for those situations when things out of our control are messing with our DOM.

First, make an observer

let’s suppose you have a video, and you want to know if that video gets any new attributes added to it. First, we make a new observer.

const observer = new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
     console.log(mutation);
     
     // do something!
  });
});

A few things to note here:

  • The argument passed in to your new MutationObserver is a callback
  • You will need to iterate over the mutations to find the mutations you want

Second, make a configuration

Before we start observing anything, we need to give some instructions on what to observe. You have a pretty good selection on how to configure your observer. With the exception of attributeFilter, all of these are boolean values, useful for answering questions you may have about the element:

childList
Have elements been added/removed directly in this element?
attributes
Has the element had any changes to attributes?
characterData
Has any text inside of the element changed?
subtree
Have elements more than one level deep changed?
attributeOldValue
Do you want the original value of the attribute?
characterDataOldValue
Do you want the original text of the element?
attributeFilter
What specific attributes should be watched?

All we want to know is if a class name is changing. Easy enough!

const observerConfig = {
  attributes: true,
  attributeOldValue: true
};

Next step: start observing

We’ve created the observer. We’ve created a configuration for it. Now, we just want to put it to use

observer.observe(document.querySelector('.myVideo', observerConfig);

We use the observe method, and pass in two arguments:

  • Element to observe
  • Configuration to use

But it feels kinda weird

If you’re thinking, “neat, but it feels weird,” you’re not alone. My long experience with JavaScript in the DOM tells me I should be able to do something like this:

document.querySelector('.video').addEventListener('mutation',mutationCallback)

But, how would we be able to decide which mutations to watch (i.e. pass in a configuration)?

¯_(ツ)_/¯

Maybe that’s why it’s not an event to listen to on an element.

And then the promise

Simply put, a promise is an object that may produce a single value at some time in the future2. I like to think of promises as an eventuality. Eventualities are events that will happen, but you don’t have the details of what happens. So a promise is a way to make sure that something always happens. This makes promises exceptionally helpful for AJAX. But it also makes them useful when you are expecting a DOM change.

The promise basics

At the most basic form, a promise needs to know when something is resolved, and when something is rejected. What you’re looking for is the means to a .then() or .catch() method. Those two little guys are your eventualities.

Start with your end goal in mind

Part of my own personal struggle with understanding promises is that I think I start in the wrong direction. I start from the promise instead of what I want to do. So, if you’re new to promises, and/or curious how we can use them with mutation observers, let’s go ahead and say that this is our end goal:

attributePromise(document.querySelector('video'), 'class').then((element) => {
    // do something because the video's classname attribute changed
}).catch((element)=> {
    // do something because the video never got a class name change
});

Begin with the wrapper function

Starting from the back, we know we want to make a function that takes an element and an attribute name as two separate arguments. So we’re looking for something like this:

function attributePromise(element,attributeName) {
    return new Promise((resolve,reject) => {

    });
}

Add your MutationObserver

We’ll throw in the observer we’ve already learned about, and its configuration. It just doesn’t quite seem like we’ve reached the finish line yet, though. For one thing, we need to actually loop through the mutation, and see if it’s…mutated.

For another, what if it never changes? How do we account for the scenario where the element never changes at all?

function attrPromise(element, attributeName) {
    return new Promise((resolve,reject) => {
        const observerConfig = {attributes: true,attributeOldValue:true};       
        const observer = new MutationObserver((mutations) => {
            mutations.forEach((mutation) => {
                         // uhhh... stuff will go here?
            });
        });
        
        observer.observe(element, observerConfig);
    });
}

Add some timed rejection

What we’ll do is make a variable called hasChanged, and we’ll set it to false. Then, we’ll throw a timer that will check every 500ms to see if that hasChanged is true.

function attributePromise(element, attributeName) {
    const rejectTime = 500;

    return new Promise((resolve,reject) => {
        let hasChanged = false;
        const observerConfig = {attributes: true,attributeOldValue:true};       
        const observer = new MutationObserver((mutations) => {
            mutations.forEach((mutation) => {

            });
        });
        
            window.setTimeout(()=>{
                if (!hasChanged) {
                    reject(element);
                }
            },rejectTime);
        
        observer.observe(element, observerConfig);
    });
}

Then, check your mutants

Now we get into into the forEach() loop. What we want to do is compare mutation.attributeName to the attributeName that’s been provided as an argument.
If the two match, we’ll do three things:

  • Set hasChanged to true so that our timer can take a breather
  • Disconnect the observer because we don’t need it any more
  • Resolve the promise
function attrPromise(element, attributeName) {
    const rejectTime = 500;

    return new Promise((resolve,reject) => {
        let hasChanged = false;
        const observerConfig = {attributes: true,attributeOldValue:true};       
        const observer = new MutationObserver((mutations) => {
            mutations.forEach((mutation) => {
                if (mutation.attributeName == attributeName) {
                    hasChanged = true;
                    observer.disconnect();
                    resolve(element, element.getAttribute(attributeName));
                }
            });
        });
        
        window.setTimeout(()=>{
            if (!hasChanged) {
                reject(element);
            }
        },rejectTime);
        
        observer.observe(element, observerConfig);
    });
}

Get off the Happy Path

So far, we’ve written a “happy path” Mutation Promise. Meaning that attributeName will always be an argument that’s been provided, and you’ll always want it to eventually reject. But, let’s go off the happy path by adding some flexibility to our function.

We’ll set a rejectTime with a default value of zero, and then add a setTimeout any time it’s greater than zero.

We’ll also modify our conditions inside of the forEach(). One condition will account for us having an attributeName AND that attributeName changing. Another will account for the scenario where we didn’t send in an attributeName at all.

function attrPromise(element, attributeName, rejectTime = 0) {
    return new Promise((resolve,reject) => {
        let hasChanged = false;
        const observerConfig = {attributes: true,attributeOldValue:true};       
        const observer = new MutationObserver((mutations) => {
            mutations.forEach((mutation) => {
                if (attributeName && mutation.attributeName == attributeName) {
                    hasChanged = true;
                    observer.disconnect();
                    resolve(element, element.getAttribute(attributeName));
                }
                
                if (!attributeName) {
                    hasChanged = true;
                    observer.disconnect();
                    resolve(element);
                }
            });
        });
        
        if (rejectTime > 0) {
            window.setTimeout(()=>{
                if (!hasChanged) {
                    reject(element);
                }
            },rejectTime * 100);
        }
        
        if (attributeName) observerConfig.attributeFilter = [attributeName];
        observer.observe(element, observerConfig);
    });
}

Link(s) or it Didn’t Happen

The raw code is in a gist for your convenience.

But if you want an example of this in action, there’s a codepen for that. Click the button, or don’t.

See the Pen Mutation Promise by Paceaux (@paceaux) on CodePen.

Tell me what you think