Why Observer APIs All Follow the Same Pattern

Modern browser APIs like ResizeObserver, MutationObserver, and IntersectionObserver consistently outperform their predecessors — resize events, deprecated Mutation Events, and scroll-based intersection checks, respectively. They share a similar four-step usage model:

  1. Instantiate the observer with a callback function via new.
  2. Handle changes inside that callback.
  3. Start observing an element with observe().
  4. Optionally stop with unobserve() or disconnect().

Here’s how that plays out with ResizeObserver:

// Step 1: Create a new observer
const observer = new ResizeObserver(observerFn)

// Step 2: Do something with the observed changes
function observerFn (entries) {
  for (let entry of entries) {
    // Do something with entry
  }
}

// Step 3: Observe an element
const element = document.querySelector('#some-element')
observer.observe(element);

// Step 4 (optional): Disconnect the observer
observer.disconnect(element)

With comments, the flow is understandable. Without them, it becomes harder to follow:

const observer = new ResizeObserver(observerFn)

function observerFn (entries) {
  for (let entry of entries) {
    // Do something with entry
  }
}

const element = document.querySelector('#some-element')
observer.observe(element);

The good news: these APIs can be made significantly easier to work with through wrapping and refactoring. Starting with ResizeObserver — the simplest of the three — we can build a clean, reusable function.

Building a Wrapper Function

Begin by moving all observer logic inside a function:

function resizeObserver () {
  // ... Do something
}

Then pass the target element as a parameter, which removes the document.querySelector step entirely:

function resizeObserver () {
  const observer = new ResizeObserver(observerFn)

  function observerFn (entries) {
    for (let entry of entries) {
      // Do something with entry
    }
  }

  const node = document.querySelector('#some-element')
  observer.observe(node);
}

The function is now versatile — it accepts any element:

function resizeObserver (element) {
  const observer = new ResizeObserver(observerFn)

  function observerFn (entries) {
    for (let entry of entries) {
      // Do something with entry
    }
  }

  observer.observe(node);
}

This alone removes the boilerplate of constructing an observer from scratch:

// Usage of the resizeObserver function
const node = document.querySelector('#some-element')
const obs = resizeObserver(node)

Handling the Callback More Cleanly

At this point, the observer function that loops through entries is duplicated across every call site. We can abstract that inner loop and accept a user-supplied callback instead, making the wrapper behave similarly to an event listener:

// Not great
function resizeObserver (node, observerFn) {
  const observer = new ResizeObserver(observerFn)
  observer.observe(node);
}

Usage becomes much lighter:

// Better 
function resizeObserver (node, callback) {
  const observer = new ResizeObserver(observerFn)

  function observerFn (entries) {
    for (let entry of entries) {
      callback(entry)
    }
  }

  observer.observe(node);
}

To maximize flexibility with no performance cost, the callback can receive both entry and the full entries array:

// Usage of the resizeObserver function
const node = document.querySelector('#some-element')
const obs = resizeObserver(node, entry => {
  // Do something with each entry
})

You can then grab entries in the callback when needed:

function resizeObserver (element, callback) {
  const observer = new ResizeObserver(observerFn)

  function observerFn (entries) {
    for (let entry of entries) {
      callback({ entry, entries })
    }
  }

  observer.observe(element);
}

Options and Configuration

ResizeObserver’s observe method accepts an options object with one property: box, which determines whether to track content-box, border-box, or device-pixel-content-box. The options should be passed through to observe:

function resizeObserver (element, options = {}) {
  const { callback, ...opts } = options
  // ...
  observer.observe(element, opts);
}

One key design choice is accepting the callback as part of the options object rather than as a separate argument. This keeps the wrapper compatible with the mutationObserver and intersectionObserver counterparts we’ll build later:

function resizeObserver (element, options = {}) {
  const { callback } = options
  const observer = new ResizeObserver(observerFn)

  function observerFn (entries) {
    for (let entry of entries) {
        callback({ entry, entries })
      }
  }

  observer.observe(element);
}

Usage then looks like this:

const obs = resizeObserver(node, {
  callback ({ entry, entries }) {
    // Do something ...
  }
})

Stopping Observation

Any good wrapper must let you tear down the observer. ResizeObserver provides two methods:

  • unobserve: Stop observing a single element.
  • disconnect: Stop observing all elements.

Since the wrapper only observes one element, both methods are functionally equivalent for our purposes — pick whichever fits your preference:

function resizeObserver (node, options = {}) {
  // ...
  return {
    unobserve(node) {
      observer.unobserve(node)
    },
    
    disconnect() {
      observer.disconnet()
    }
  }
}

Putting it together, the final wrapper offers a complete, cleaner API:

const obs = resizeObserver(node, {
  callback ({ entry, entries }) {
    // Do something ...
  }
})

// Stops observing all elements 
obs.disconect()

Here’s the full code:

export function resizeObserver(node, options = {}) {
  const observer = new ResizeObserver(observerFn)
  const { callback, ...opts } = options

  function observerFn(entries) {
    for (const entry of entries) {
      // Callback pattern
      if (callback) callback({ entry, entries, observer })
      // Event listener pattern
      else {
        node.dispatchEvent(
          new CustomEvent('resize-obs', {
            detail: { entry, entries, observer },
          })
        )
      }
    }
  }
 
  observer.observe(node)

  return {
    unobserve(node) {
      observer.unobserve(node)
    },
    
    disconnect() {
      observer.disconnect()
    }
  }
}

Going Further

The Splendid Labz utils library contains an enhanced version of this same resizeObserver wrapper. It goes a step further by observing and unobserveing multiple elements in a single call:

const items = document.querySelectorAll('.elements')
const obs = resizeObserver(items, {
  callback ({ entry, entries }) {
    /* Do what you want here */
  }
})

// Unobserves two items at once
const subset = [items[0], items[1]]
obs.unobserve(subset)