Making DOM API’s reactive

Learn how to use the Intersection Observer API in Vue with TypeScript by transforming its implementation into a reusable composable


There is a big joy in writing Vue composables to share reactive state and logic. We can go a bit further and also even make the interactions with DOM reactive. It’s pretty neat.

In this example (extracted and condensed from my Composition API workshop), we are going to wrap the Intersection Observer API using Vue reactivity API’s.

The scenario

Let’s imagine we need to know when some UI element is being visible. We can use Intersection Observer API for that. That way, we can observe when a DOM element is intersecting into the viewport (the API allows customizing how you want that element to be observed).

JS
const emit = defineEmits(['ad:visible']);

const ad = ref(null); // Template ref

const observer = new IntersectionObserver(entries => {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            emit('ad:visible', { name: 'System Ad', when: new Date().toISOString() });
        }
    });
})

onMounted(() => {
    if (ad.value) {
        observer.observe(ad.value);
    }
})

    

Now, when the element is observed, we use our standard way of communicating: sending events.

JS
if (entry.isIntersecting) {
    emit('ad:visible', { name: 'Footer Ad', when: new Date().toISOString() });
}

    

Those events are collected, and its information used to display some metadata. In a real world scenario, we could send that data to some third party tracking service, perhaps.

JS
const handleAdVisible = (ad: { name: string; when: string }) => {
  // Do something usufeul...
};

<BlogPost @ad:visible="handleAdVisible" />
<AppFooter @ad:visible="handleAdVisible" />

    

This is working, but our architecture is not scalable. What happens if we need to observe elements that are way down the component hierarchy chain?. Well provide/inject I hear you answering and yes, it will work. But what if the element to observe lives inside a completely different component tree?.

To make our life easier, we can extract this logic into a tiny composable that, among other benefits, it will be available to consume everywhere in our App.

But… how do we “transform” our current implementation into a composable?.

Good question.

The formula

You should start porting logic piece by piece. To help you to understand what to look and where, here is this nifty formula I personally use:

  1. Look for common (context) reactive state (shared).
  2. Look for derived computed values (transformations).
  3. Look for changes (mutations).
  4. Expose only what's needed (return).

The solution

Let’s apply the formula together.

Look for common (context) reactive state (shared).

Meaning, any piece of state that is going to be consumed from various components in the App. Given that our composable is a stateless one, it can seem we don’t have an obvious candidate, but there is one. What about the log of all the intersection events?. Let’s call it intersectionLog and expose as a reactive reference.

TS
interface IntersectionLog {
    name: string,
    when: string
}

export const intersectionLog = ref<IntersectionLog[]>([])

    

Look for derived computed values (transformations).

Are we doing any transformation with the state we are sharing?. No. But this is a rare case. Most of the time you should not expose your mutable state directly and use computed or readonly instead.

Just for the shake of this article, let’s add a computer property that tells if there is at least one log recorded.

JS
const areThereLogs = computed(() => !!intersectionLog.value.length)

    

Look for changes (mutations).

There is a clear mutation here: when we push a new log entry inside intersectionLog. But do we need to expose it?. Again, it depends on your way of building this kind of logic pieces, but IMO the less we expose, the better. So let’s keep it private to the function.

In a more typical scenario, you will have to expose some kind of method to mutate your reactive state. After all, we are using reactivity because we want to be notified when our state changes.

Expose only what's needed (return).

One of the main benefits of using composables versus mixins o extends is the ability to expose only what’s essential. Nothing more.

In our case, we should expose some sort of abstraction that lets us work with the Intersection Observer API. We can also mimic the API, wrapping its native method calls while maintaining its nomenclature.

But first, let’s add the bulk of the work with the API.

TS
const observer = new IntersectionObserver(entries => {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
                intersectionLog.value.push({ name, when: new Date().toISOString() })
        } else {
            isIntersecting.value = false
        }
    });
})

    

Now, having this observer API class instance, we can start working on it. For example, returning an observe method, but also unobserve and disconnect methods to make it fully operational.

JS
const observe = () => {
        if (element.value)
                observer.observe(target)
}

const unobserve = () => {
        if (element.value)
                observer.unobserve(target)
}

const disconnect = () => {
        observer.diconnect()
}

    

How to start this thing

Ok, we are ready to test our new useIntersectionObserver composable, but we forgot to answer one of the most critical questions: what do this need to do its job?. Well, let’s think about it.

For starters, if we want to observe a target DOM elements, we will require at least one, right?. We can ask for the raw HTMLElement, but we have to deal with this temporal zone while the reference will be null.

TS
const element = ref<HTMLElement | null>(null)

    

We can deal with it in the implementation or in the composable itself. I personally prefer the latter option. This will mean some defensive checks, but that’s ok.

Also, we will need some kind of identifier to create the log. This is a great opportunity to use the Options pattern to allow for any customization. We can even take into consideration the very own Intersection Observer API options extending them.

TS
interface Options extends IntersectionObserverInit {
    name: string
    // Add any other option needed
}

    

Then we can the options in our composable.

TS
const { name, ...intersectionOptions } = options

    

Will all have been taken care of, we can (at last) create our composable function.

TS
export function useIntersectionObserver(target: Ref<HTMLElement | null>, options: Options) {
    ...
}

    

Extra tweaks

I believe we have some room for refactor and improvements.

Use component lifecycle hooks

It’s somewhat unknown you can use the lifecycle hooks of the component where you are using your composable. This is done automatically, you just have to make the connections.

So, inside our useIntersectionObserver function, you can add the callbacks.

JS
onMounted(observe)

onUnmounted(unobserve)

    

This way, you don’t have to worry about staring and stopping observation. Your component will do if for you.

JS
// This will launch "observer" a soon as the component is mounted
useIntersectionObserver(ad, { name: 'Blogpost Ad' })

    

Allow for side effects

Side effects had a bad name. And it’s a shame because we need side effects. The key is placing them where they belong.

A pretty realistic situation would be: what if I want to be notified when an intersection event is occurring?.

We can allow for a callback in our options and then execute it.

JS
interface Options extends IntersectionObserverInit {
    name: string
    fn: () => void
}

    

But what If a callback is not enough?. In these situations, I prefer to offer an optional piece of reactive state. You can use or not. It is up to you. For instance.

JS
const isIntersecting = ref<boolean>(false)

...

return {
    readonly(isIntersecting),
    ...
}

    

Then you can watch it locally (in your component) a do your side effects.

JS
watch(isIntersecting, () => {
    // Side effects here...
})

    

Final result

In all its glory.

TS
import { ref, computed, readonly, onMounted, onUnmounted, type Ref } from 'vue';

interface Options extends IntersectionObserverInit {
  name: string;
  fn?: () => void;
}

interface IntersectionLog {
  name: string;
  when: string;
}

export const intersectionLog = ref<IntersectionLog[]>([]);

export default function useIntersectionObserver(
  target: Ref<HTMLElement | null>,
  options: Options
) {
  const { name, fn, ...intersectionOptions } = options;
  const isIntersecting = ref(false);
  const areThereLogs = computed(() => !!intersectionLog.value.length);

  const observer = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        intersectionLog.value.push({ name, when: new Date().toISOString() });

        if (typeof fn === 'function') {
          fn();
        }
      } else {
        isIntersecting.value = false;
      }
    });
  });

  const observe = () => {
    if (target.value) observer.observe(target.value);
  };

  const unobserve = () => {
    if (target.value) observer.unobserve(target.value);
  };

  const disconnect = () => {
    observer.disconnect();
  };

  onMounted(observe);

  onUnmounted(disconnect);

  return {
    isIntersecting: readonly(isIntersecting),
    areThereLogs,
    observe,
    unobserve,
    disconnect,
  };
}

    

Demo

As always, here’s a demo.

Juan Andrés Núñez
Juan Andrés Núñez
Senior Frontend Engineer. Vue.js specialist. Speaker. Professional teacher. Stoic.