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!