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).
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.
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.
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:
- Look for common (context) reactive state (shared).
- Look for derived computed values (transformations).
- Look for changes (mutations).
- 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.
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.
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.
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.
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
.
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.
interface Options extends IntersectionObserverInit {
name: string
// Add any other option needed
}
Then we can the options in our composable.
const { name, ...intersectionOptions } = options
Will all have been taken care of, we can (at last) create our composable function.
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.
onMounted(observe)
onUnmounted(unobserve)
This way, you don’t have to worry about staring and stopping observation. Your component will do if for you.
// 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.
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.
const isIntersecting = ref<boolean>(false)
...
return {
readonly(isIntersecting),
...
}
Then you can watch
it locally (in your component) a do your side effects.
watch(isIntersecting, () => {
// Side effects here...
})
Final result
In all its glory.
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.