Implementing MutationObserver for Reactive DOM Updates on a Website
MutationObserver watches for changes in the DOM tree: adding/removing nodes, changing attributes, changing text content. Works asynchronously via microtasks — callback is called after the current synchronous code completes, batching mutations.
Where it's needed: integration with legacy code, third-party widgets, CMS editors, tracking page changes, implementing custom elements without Web Components API, tracking dynamically inserted content.
Basic Setup
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
switch (mutation.type) {
case 'childList':
// mutation.addedNodes — added nodes (NodeList)
// mutation.removedNodes — removed nodes
break
case 'attributes':
// mutation.attributeName — attribute name
// mutation.oldValue — old value (if attributeOldValue: true)
break
case 'characterData':
// mutation.oldValue — old text (if characterDataOldValue: true)
break
}
}
})
observer.observe(element, {
childList: true, // watch for adding/removing child nodes
subtree: true, // recursively throughout subtree
attributes: true, // watch for attributes
attributeFilter: ['class', 'data-state'], // only these attributes
attributeOldValue: true, // save old value
characterData: false, // watch for text content
})
observer.disconnect() // disconnect
observer.takeRecords() // get accumulated mutations and clear queue
Waiting for Element to Appear in DOM
Useful for working with third-party widgets that insert elements asynchronously:
function waitForElement<T extends HTMLElement>(
selector: string,
root: HTMLElement | Document = document,
timeoutMs = 10000
): Promise<T> {
const existing = root.querySelector<T>(selector)
if (existing) return Promise.resolve(existing)
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
observer.disconnect()
reject(new Error(`Element "${selector}" didn't appear in ${timeoutMs}ms`))
}, timeoutMs)
const observer = new MutationObserver(() => {
const el = root.querySelector<T>(selector)
if (el) {
clearTimeout(timer)
observer.disconnect()
resolve(el)
}
})
observer.observe(root, { childList: true, subtree: true })
})
}
// Usage:
const chatWidget = await waitForElement<HTMLDivElement>('#intercom-container')
chatWidget.style.bottom = '80px' // override widget style
Tracking Dynamically Added Elements
When you need to initialize logic for elements that may appear at any time:
type ElementHandler = (element: HTMLElement) => (() => void) | void
function watchForElements(
selector: string,
handler: ElementHandler,
root: HTMLElement | Document = document
): () => void {
const cleanups = new Map<HTMLElement, () => void>()
function processElement(el: HTMLElement): void {
if (cleanups.has(el)) return
const cleanup = handler(el)
if (cleanup) cleanups.set(el, cleanup)
}
function processRemoval(el: HTMLElement): void {
const cleanup = cleanups.get(el)
if (cleanup) {
cleanup()
cleanups.delete(el)
}
}
// Initialize existing elements
root.querySelectorAll<HTMLElement>(selector).forEach(processElement)
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
mutation.addedNodes.forEach((node) => {
if (node.nodeType !== Node.ELEMENT_NODE) return
const el = node as HTMLElement
if (el.matches(selector)) processElement(el)
el.querySelectorAll<HTMLElement>(selector).forEach(processElement)
})
mutation.removedNodes.forEach((node) => {
if (node.nodeType !== Node.ELEMENT_NODE) return
const el = node as HTMLElement
if (el.matches(selector)) processRemoval(el)
el.querySelectorAll<HTMLElement>(selector).forEach(processRemoval)
})
}
})
observer.observe(root, { childList: true, subtree: true })
return () => {
observer.disconnect()
cleanups.forEach((cleanup) => cleanup())
cleanups.clear()
}
}
// Example: automatically initialize custom components
const stop = watchForElements('[data-tooltip]', (el) => {
const tooltip = new TooltipController(el)
return () => tooltip.destroy()
})
Tracking Attribute Changes
function watchAttribute(
element: HTMLElement,
attribute: string,
onChange: (newValue: string | null, oldValue: string | null) => void
): () => void {
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.attributeName === attribute) {
onChange(
element.getAttribute(attribute),
mutation.oldValue
)
}
}
})
observer.observe(element, {
attributes: true,
attributeFilter: [attribute],
attributeOldValue: true,
})
return () => observer.disconnect()
}
// Sync with third-party component class
watchAttribute(someWidget, 'class', (newValue, oldValue) => {
const wasOpen = oldValue?.includes('is-open')
const isOpen = newValue?.includes('is-open')
if (!wasOpen && isOpen) onWidgetOpen()
if (wasOpen && !isOpen) onWidgetClose()
})
React Hook
function useMutationObserver(
target: HTMLElement | null,
callback: MutationCallback,
options: MutationObserverInit
): void {
const callbackRef = useRef(callback)
callbackRef.current = callback
useEffect(() => {
if (!target) return
const observer = new MutationObserver((...args) => callbackRef.current(...args))
observer.observe(target, options)
return () => observer.disconnect()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [target, JSON.stringify(options)])
}
// Usage:
function DynamicContent() {
const containerRef = useRef<HTMLDivElement>(null)
const [childCount, setChildCount] = useState(0)
useMutationObserver(
containerRef.current,
(mutations) => {
setChildCount(containerRef.current?.childElementCount ?? 0)
},
{ childList: true }
)
return <div ref={containerRef}>{/* dynamic content */}</div>
}
Performance
MutationObserver can accumulate thousands of mutations per second with active DOM changes. Few rules:
- Don't use
subtree: truewithout necessity — expensive observation - Filter mutations inside callback as quickly as possible
- Use
observer.takeRecords()to force queue reset before disconnect - Don't access DOM inside callback without necessity — every
querySelectoris a layout query
Timeline: 0.5–1 day depending on scenario complexity.







