MutationObserver for Reactive DOM Updates

Our company is engaged in the development, support and maintenance of sites of any complexity. From simple one-page sites to large-scale cluster systems built on micro services. Experience of developers is confirmed by certificates from vendors.
Development and maintenance of all types of websites:
Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Our competencies:
Development stages
Latest works
  • image_website-b2b-advance_0.png
    B2B ADVANCE company website development
    1215
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1161
  • image_websites_belfingroup_462_0.webp
    Website development for BELFINGROUP
    852
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1043
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    823
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Website development for FIXPER company
    815

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: true without 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 querySelector is a layout query

Timeline: 0.5–1 day depending on scenario complexity.