While driving my kids to school this morning, I had an interesting thought. Is it possible for a web component to recognize, and respond, when its inner DOM contents have changed? Turns out of course it is, and the answer isn't really depenedant on web components, but is a baked-in part of the web platform, the MutationObserver. Here's what I built as a way to test it out.

The Initial Web Component

I began with a simple web component that had the following simple feature - count the number of images inside it and report. So we can start with this HTML:

<img-counter>
	<p>
		<img src="https://placehold.co/60x40">
	</p>
	<div>
		<img src="https://placehold.co/40x40">
	</div>
	<img src="https://placehold.co/90x90">
</img-counter>

And build a simple component:

class ImgCounter extends HTMLElement {

	constructor() {
		super();
	}
	
	connectedCallback() {
		let imgs = this.querySelectorAll('img');
		this.innerHTML += `<p>There are <strong>${imgs.length}</strong> images in me.</p>`;
	}
	
}

if(!customElements.get('img-counter')) customElements.define('img-counter', ImgCounter);

It just uses querySelectorAll to count the img node inside it. For my initial HTML, this reports 3 of course.

I then added a simple button to my HTML:

<button id="testAdd">Add Img</button>

And a bit of code:

document.querySelector('#testAdd').addEventListener('click', () => {
	document.querySelector('img-counter').innerHTML = '<p>New: <img src="https://placehold.co/100x100"></p>' + document.querySelector('img-counter').innerHTML;
});

When run, it will add a new image, but obviously, the counter won't update. Here's a CodePen of this initial version:

See the Pen Img Counter WC 1 by Raymond Camden (@cfjedimaster) on CodePen.

Enter - the MutationObserver

The MDN docs on MutationObserver are pretty good, as always. I won't repeat what's written there but the basics are:

  • Define what you want to observe under a DOM element - which includes the subtree, childList, and attributes
  • Write your callback
  • Define the observer based on the callback
  • Tie the observer to the DOM root you want to watch

So my thinking was...

  • Move out my 'count images and update display' to a function
  • Add a mutation observer and when things change, re-run the new function

My first attempt was rather naive, but here it is in code form, not CodePen, for reasons that will be clear soon:

class ImgCounter extends HTMLElement {

	constructor() {
		super();
	}
	
	connectedCallback() {
		
		this.renderCount();
		
		const mutationObserver = (mutationList, observer) => {
			this.renderCount();
		};
		
		const observer = new MutationObserver(mutationObserver);
		observer.observe(this, { 
			childList: true, subtree: true 
		});
	}
	
	renderCount() {
		let imgs = this.querySelectorAll('img');
		this.innerHTML += `<div><p>There are <strong>${imgs.length}</strong> images in me.</p></div>`;
	}

}

So the MutationObserver callback is sent information about what changed, and in my simple little mind, I figured, I don't care. If something changes, just rerun the count to count images.

Look at that code and see if you can figure out the issue. If you can, leave me a comment below.

So yes, this "worked", but this is what happened:

  • I clicked the button to add a new image
  • The mutation observer fired and was like, cool, new shit to do, run renderCount
  • renderCount got the images and updated the HTML to reflect the new count
  • Hey guess what, renderCount changed the DOM tree, let's run the observer again
  • Repeat until the heat death of the universe

I had to tweak things a bit, but here's the final version, and I'll explain what I did:

class ImgCounter extends HTMLElement {

	#myObserver;
	
	constructor() {
		super();
	}
	
	connectedCallback() {
		
		// create the div we'll use to monitor images:
		this.innerHTML += '<div id="imgcountertext"></div>';
		
		this.renderCount();
		
		const mutationObserver = (mutationList, observer) => {			
			for(const m of mutationList) {
				if(m.target === this) {
					this.renderCount();
				}
			}
		};
		
		this.myObserver = new MutationObserver(mutationObserver);
		this.myObserver.observe(this, { 
			childList: true, subtree: true 
		});
	}
	
	disconnectedCallback() {
		this.myObserver.disconnect();
	}
	
	renderCount() {
		let imgs = this.querySelectorAll('img');
		this.querySelector('#imgcountertext').innerHTML = `There are <strong>${imgs.length}</strong> images in me.`;
	}

}

if(!customElements.get('img-counter')) customElements.define('img-counter', ImgCounter);

document.querySelector('#testAdd').addEventListener('click', () => {
	document.querySelector('img-counter').innerHTML = '<p>New: <img src="https://placehold.co/100x100"></p>' + document.querySelector('img-counter').innerHTML;
});

I initially had said I didn't care about what was in the list of items changed in the mutation observer, but I noticed that the target value was different when I specifically added my image count report. To help with this, I'm now using a div tag with an ID and renderCount modifies that.

When a new image (or anything) is added directly inside the component, my target value is img-counter, or this, which means I can run renderCount on it. When renderCount runs, the target of the mutation is its own div.

Also, I noticed that the MutationObserver talks specifically called out the disconnect method as a way of ending the DOM observation. That feels pretty important, and web components make it easy with the disconnectedCallback method.

All in all, it works well now (as far as I know ;), and you can test it yourself below:

See the Pen Img Counter WC 2 by Raymond Camden (@cfjedimaster) on CodePen.

Remember, MutationObserver can absolutely be used outside of web components. Also note that if you only want to respond to an attribute change in a web component, that's really easy as it's baked into the spec. As always, let me know what you think, and I've got a strong feeling that someone going to show me a better way of doing this, and I'd be happy to see it!