Diagram of content boxes intersecting the viewport.
Credit blog.arnellebalane.com

Build An Intersection Observer Directive In Vue

In this post, I want to share my experience integrating the IntersectionObserver API into a Vue app. By the end, we’ll have a custom directive that abstracts dynamically registering and unregistering DOM elements with an observer.

Intersection Observer

When you need to track an element coming into view, watching document scroll and calculating element offsets used to be the only way. The math isn’t particularly complex, but knowing which layout properties to use and just how to calculate position relative to the right elements is a painful task. In addition, since scroll fires a large amount of events very rapidly, it’s easy to cause jank if your calculations and subsequent processing exceeds the frame budget–most likely because too many events are being processed within a single frame.

Enter the IntersectionObserver. Aptly named, an instance of IntersectionObserver can observe many elements and invoke a callback when elements intersect or stop intersecting with the viewport or another element (usually some scrollable container). The built-in class is able to efficiently calculate intersection, and it does so with much simpler code (no math!). On top of this nice abstraction, IntersectionObserver also handles scenarios that are often forgotten (like resize events) as well as extra difficult scenarios (like <iframe> elements).

Before we start integrating this API into Vue, here are resources for more background on Vue directives and IntersectionObserver:

Getting Started

One of the first challenges of using IntersectionObserver in Vue is that our component’s DOM is an artifact of our template and state. Declarative, component UI aims to keep us away from the DOM, but working with our observer requires plugging it into our real elements, not our template. This means we have to get our hands dirty, dig into our components raw elements, and be wary of the component lifecycle.

Quick And Dirty

First things first: let’s just prototype something and make it work. I’m going to start with a codesandbox vue project, and replace the App.vue component with a big list of items to overflow the viewport. With some scrollable dummy content, we can task ourselves with detecting when an item comes in/out of view.

Make A Big List

Let’s start by making our overflowing list. To create a list of dummy elements, we’ll use a computed property called range. This property doesn’t use any fields from the component instance, so it’s effectively a constant. The shortest way to create a range-like array of numbers 1-100 is to use a trick based on iterables.

Vue.extend({
  computed: {
    range() {
      return Array.from({ length: 100 }, (_, i) => i + 1);
    },
  },
});

Array.from accepts any iterable as it’s first parameter, and then an optional mapping function to transform each item yielded from the iterable. In what feels like a total cheat, we create a 100 item iterable by simply creating an object with a numeric length property: { length: 100 }. Our transform skips the values yielded from our iterable (since they are void) and instead returns the index plus 1. You can imagine the internals of Array.from starting up an old fashioned for loop and calling our transform function on each iteration:

// The default transform just returns whatever is yielded from the iterable.
const identity = x => x;

const Array = {
  from(iterable, transform = identity) {
    let list = [];
    for (let i = 0; i < iterable.length; i++) {
      list.push(transform(iterable[i], i));
    }
    return list;
  },
};

To render the list, we can use a v-for directive. We’ll place a data attribute referencing our id so later we can reference the element from the intersection observer’s callback. We’ll also place a ref here so we can pass these elements to our observer to be observed. Placing a ref on an element with v-for will give us an array of elements at vm.$refs.items.

<template>
  <ul class="list">
    <li ref="items" v-for="i in range" :key="i" class="item" :data-id="i">
      Item Number #{{i}}
    </li>
  </ul>
</template>

Managing State

Now we need to figure out how to store which items are in view. We could fill an array with ids that are in view, but when reacting to changes from the observer, we would have to filter the list on each entry that is not intersecting, and push each entry that is intersecting. That makes additions cheap, but deletions potentially expensive.

To improve the performance implications of the array we could use a set. The Set#has, Set#add and Set#delete methods would make it fast and easy to remove items leaving view and add items entering view. The problem with a set is that Vue 2.x cannot observe its changes. We’ll have to wait for Vue 3.x to leverage Set and other newer built-ins.

We can use an object to store which ids are in view by using the id as the key and a boolean as the value–true indicating it is in view, false or no key present indicating out of view. This makes adding items as simple as adding a new property with a value of true, and removing items can be excluded from the object or simply toggled to false. This has one caveat: Vue cannot observe changes to new or deleted properties. We’ll have to be careful to either use Vue.set or replace our object with a new one so Vue will trigger its reactivity system to observe the new object with additional properties.

Vue.extend({
  data() {
    return {
      // Record<string, boolean>
      inViewById: {},
    };
  },
});

In addition to the reactivity caveats, we’ll need to take into account the fact that our numeric ids will be cast to strings when used as object keys. This will just be for a ticker display of the items currently in view. We will want to sort entries so we aren’t looking at a confusing jumble of item ids.

Vue.extend({
  computed: {
    inView() {
      return Object.entries(this.inViewById)
        .filter(this.isInView)
        .map(this.pluckId)
        .sort(this.sortAtoi);
    },
  },
  methods: {
    // Destructure the Object Entry of key, value (dropping the key)
    isInView([, inView]) {
      return inView;
    },
    pluckId([i]) {
      return i;
    },
    // Sort ascii to int (a to i) is a sort function
    // that properly sorts numbers when passed as strings.
    sortAtoi(a, b) {
      return Number(a) - Number(b);
    },
  },
});

Create The Observer

Finally, we can instantiate an IntersectionObserver. We could do this in our component data, but we don’t need it to be reactive, and I’m not even sure how much of the observer’s properties are Vue can make reactive. We could use the created lifecycle hook, but our component DOM won’t be accessible. We’ll use the mounted lifecycle hook so we have everything at our fingertips and also because that hook is not run in SSR contexts.

We’ll instantiate the IntersectionObserver, which accepts a callback to handle changes on its observed elements. We’ll set that up as a method we’ll create next. We could also pass an object of options as the second parameter, but let’s just go with the defaults for now.

After creating the observer, we’ll iterate through our list of elements using the ref placed on the v-for. We tell our new observer to observe each element, and then we’ll save a handle to our observer so we can disconnect it and release it’s resources before our component is destroyed.

Vue.extend({
  mounted() {
    let observer = new IntersectionObserver(this.handleIntersection);
    for (let el of this.$refs.items) {
      observer.observe(el);
    }
    this.observer = observer;
  },
  beforeDestroy() {
    this.observer.disconnect();
  },
});

So here’s where it gets a little interesting. Our observer callback is invoked with an array of IntersectionObserverEntry objects and a reference to our observer (which we have saved on our component instance). We’re going to get one entry for each element we observed–so every element in our list. We can iterate through this list and use the entry’s isIntersecting property to determine whether or not it’s in view.

The interesting part is managing our state since we have to give Vue fresh objects if we want to add or remove properties from our map of what is in view. Here we’ve created a method to clone our map, but only adding items to the map if they’re in view. We can keep the object smaller this way which benefits our clone process as well as our sorted list of ids in view.

Once we have a fresh map of what is in view, we can iterate the entries and sync up visibility with our state. If an item is intersecting, we set that id to true. If it’s not intersecting, we need to check if it’s visible in the old map and set it to false. Those will be the items leaving view. By only setting it to false when true, we continue to preserve the smallest size map we can.

The last thing to do is to assign the new map on our component instance. This will trigger Vue to observe the new object, detect changes and re-render.

Vue.extend({
  methods: {
    handleIntersection(entries, observer) {
      let inViewById = this.cloneInViewById();

      for (let entry of entries) {
        let id = entry.target.dataset.id;
        if (entry.isIntersecting) {
          // You could check if this was not already true
          // to determine the item just came into view.
          inViewById[id] = entry.isIntersecting;
        } else if (inViewById[id]) {
          // Leaving view.
          inViewById[id] = false;
        }
      }

      this.inViewById = inViewById;
    },
    cloneInViewById() {
      let inViewById = {};
      for (let [id, inView] of Object.entries(this.inViewById)) {
        if (inView) {
          inViewById[id] = true;
        }
      }
      return inViewById;
    },
  },
});

Quick And Dirty Result

Now to see the code in action! I’ve built the codesandbox using our snippets. Our component is correctly tracking which items are visible on screen and updating our ticker. This means we set up the observer properly and that we’re managing our state in a Vue 2.x friendly way.

Problems

Now that we have a working implementation, what are we missing?

Our example shows a static list of elements, but what happens if we have a dynamic list? Items may be added or removed by user interaction, but our observer will still be watching the original set of items. What happens if we render an empty list when the component is loaded, then we get supplied a long list from a data fetch? Our observer will sit idle and not observe anything.

What if we want to use an observer passed as a prop from a parent component? We’ll need to be reactive to that observer changing. We might also need to be prepared for not being given an observer at first, or the observer disappearing during the component’s lifecycle.

Observe Directive

What we need is a way to hook into the lower level Vue mechanics of when elements are added and removed from a component’s DOM. Thankfully there’s a way to do this, and it’s a first-class Vue API: custom directives.

Refactor To Directive

Now we need to see what we should extract from our prototype and into a directive. Our directive won’t have any control over the observer except that it will be given as a directive prop. We’re going to want to cover use cases for element insertion, update, and directive unbind. Using the directive should be a one-line change to pass our observer to our directive. Here it is in the context of our big list:

<template>
  <ul class="list">
    <li
      v-observe="observer"
      ref="items"
      v-for="i in range"
      :key="i"
      class="item"
      :data-id="i"
    >
      Item Number #{{i}}
    </li>
  </ul>
</template>

Insertion

When an element is inserted, if we are given an observer, register the element with the observer.

Update: Not Observed

If we are given an observer, register the element with observer.

Update: Already Observed

If we are given an observer, check to see if it’s the same observer. If it’s different, attempt to unregister with the old observer and register with the new observer. It it’s the same observer, do nothing.

If we aren’t given an observer, attempt to unregister with the old observer.

Directive Unbind

If we are being observed, attempt to unregister with the old observer.

Implementation

As you can see, there are a painful amount of use cases to support for a seamless abstraction. After listing the requirements, I can see that we are going to need to cache two pieces of state: the observer and whether or not we are currently being observed. We can use the observer’s existence to derive whether or not we are being observed, but I find adding a data attribute makes it easier to peek in and see if things are working or not.

To track state, we’ll cache the observer directly on the element. To ensure we don’t conflict with any DOM properties both present and future, we can create a local symbol that will give us exclusive access to our cached observer. We’ll make the data attribute appear in the DOM as data-v-observed="yes|no" by using the element’s dataset in camelcase: element.dataset.vObserved = "yes|no" (read the pipe character as an “or”).

What follows is a full directive implementation that seems too tedious to go through line-by-line. The insert and unbind cases are relatively easy to follow, but update is tricky. I’ve done my best to reduce the complexity of the many possible cases by leveraging early returns and using names that hopefully make things more readable.

const yes = "yes";
const no = "no";
const kObserver = Symbol("v-observe");

function markObserved(el) {
  el.dataset.vObserved = yes;
}
function markNotObserved(el) {
  el.dataset.vObserved = no;
}
function cacheObserver(el, observer) {
  el[kObserver] = observer;
}
function removeCachedObserver(el) {
  el[kObserver] = undefined;
}

export default {
  inserted(el, { value: observer }) {
    if (observer instanceof IntersectionObserver) {
      observer.observe(el);
      markObserved(el);
      cacheObserver(el, observer);
    } else {
      markNotObserved(el);
      removeCachedObserver(el);
    }
  },

  update(el, { value: observer }) {
    let cached = el[kObserver];
    let sameObserver = observer === cached;
    let observed = el.dataset.vObserved === yes;
    let givenObserver = observer instanceof IntersectionObserver;

    if (!observed) {
      if (givenObserver) {
        observer.observe(el);
        markObserved(el);
        cacheObserver(el, observer);
      }

      return;
    }

    if (!givenObserver) {
      markNotObserved(el);
      if (cached) {
        cached.unobserve(el);
        removeCachedObserver(el);
      }
      return;
    }

    if (sameObserver) {
      return;
    }

    if (cached) {
      cached.unobserve(el);
    }

    observer.observe(el);
    markObserved(el);
    cacheObserver(el, observer);
  },

  unbind(el) {
    let cached = el[kObserver];
    if (cached instanceof IntersectionObserver) {
      cached.unobserve(el);
    }
    markNotObserved(el);
    removeCachedObserver(el);
  },
};

Final Result

And here you have it–our prototype converted to use our custom v-observe directive! She still works as before, but now you should be able to hot swap items in the list as well as change out intersection observers.