Convirtiendo en reactivas las API del DOM

Aprende a utilizar la API de Intersection Observer en Vue con TypeScript convirtiendo su implementación en un composable reutilizable


Escribir composables en Vue para compartir estado reactivo y lógica es un placer. Pero podemos ir un paso más allá y hacer que las interacciones con el DOM también sean reactivas. Suena bien, ¿no?.

En este ejemplo (extraído y condensado de mi taller de Composition API), vamos a envolver la API de Intersection Observer usando las APIs de reactividad de Vue.

El escenario

Imagina que necesitamos detectar cuándo un elemento de la interfaz de usuario es visible. Para eso, podemos usar la API de Intersection Observer, que nos permite observar si un elemento del DOM está intersectando con el viewport. Además, la API nos da opciones para personalizar cómo queremos que se realice esa observación.

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

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

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);
    }
})

    

Ahora, cuando el elemento es observado, usamos la forma estándar de comunicación entre componentes: eventos.

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

    

Estos eventos se recopilan y su información se usa para mostrar metadatos. En un caso real, podríamos enviar estos datos a un servicio de analítica o seguimiento.

JS
const handleAdVisible = (ad: { name: string; when: string }) => {
  // Hacer algo útil...
};

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

    

Esto funciona, pero la arquitectura no es escalable. ¿Qué pasa si necesitamos observar elementos que están muy abajo en la jerarquía de componentes? Sí, podríamos usar provide/inject, y funcionaría. Pero, ¿y si el elemento a observar está en un árbol de componentes completamente distinto?.

Para simplificarnos la vida, podemos extraer esta lógica en un pequeño composable que, entre otras ventajas, podremos usar en cualquier parte de la aplicación.

Pero… ¿Cómo convertimos nuestra implementación actual en un composable?

Buena pregunta.

La fórmula

Cuando conviertas lógica en composables, sigue esta fórmula que uso personalmente:

  1. Identifica el estado reactivo común (compartido).
  2. Identifica los valores computados derivados (transformaciones).
  3. Identifica los cambios (mutaciones).
  4. Expón solo lo necesario (return).

La solución

Apliquemos la fórmula juntos.

Estado reactivo común (compartido)

Cualquier parte del estado que vaya a ser utilizada por varios componentes en la aplicación. En este caso, nuestro composable es stateless, así que podríamos pensar que no hay un candidato obvio. Pero sí lo hay: el registro de eventos de intersección. Llamémoslo intersectionLog y hagámoslo reactivo.

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

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

    

Valores computados derivados (transformaciones)

¿Estamos transformando el estado que compartimos? No. Pero este es un caso raro. La mayoría de las veces no deberías exponer directamente tu estado mutable, sino usar computed o readonly.

Solo para este ejemplo, añadamos una propiedad computada que nos diga si hay al menos un registro guardado.

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

    

Identificar los cambios (mutaciones)

Hay una mutación clara: cuando añadimos un nuevo evento al intersectionLog. Pero, ¿realmente necesitamos exponerlo? Dependerá de cómo enfoques tu diseño, pero en mi opinión, cuanto menos expongamos, mejor. Así que dejémoslo privado dentro del composable.

En un caso más típico, sí tendríamos que exponer algún método para modificar el estado reactivo. Al final, usamos reactividad porque queremos ser notificados cuando el estado cambia.

Exponer solo lo necesario (return)

Una de las grandes ventajas de los composables frente a mixins o extends es que podemos exponer solo lo esencial. Nada más.

En nuestro caso, necesitamos una abstracción que nos permita trabajar con la API de Intersection Observer. También podemos imitar su API, envolviendo sus métodos nativos y manteniendo su nomenclatura.

Pero antes, añadamos la lógica principal de la 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
        }
    });
})

    

Ahora que tenemos la instancia de observer, podemos trabajar sobre ella. Por ejemplo, devolviendo un método observe, y también unobserve y disconnect, para hacerlo completamente funcional.

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

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

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

    

Cómo iniciarlo

Vale, ya podemos probar nuestro nuevo composable useIntersectionObserver, pero falta una pregunta clave: ¿qué necesita para funcionar?

Para empezar, si queremos observar elementos en el DOM, necesitamos al menos uno. Podemos pedir el HTMLElement directamente, pero habrá momentos en los que la referencia será null.

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

    

Podemos manejar esto en la implementación o en el propio composable. Prefiero la segunda opción. Implica comprobaciones defensivas, pero es lo más seguro.

También necesitaremos algún identificador para registrar los eventos. Esta es una buena ocasión para usar el patrón de opciones y permitir personalización.

TS
interface Options extends IntersectionObserverInit {
    name: string
}

    

Con esto listo, podemos definir nuestro composable.

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

    

Ajustes extra

Usar hooks del ciclo de vida

Lo que muchos no saben es que puedes usar los hooks del ciclo de vida en un composable. Se hace automáticamente, solo tienes que conectarlos.

Dentro de useIntersectionObserver, añadimos esto:

JS
onMounted(observe)
onUnmounted(disconnect)

    

Así, no necesitas preocuparte por iniciar y detener la observación. El componente lo hará por ti.

JS
useIntersectionObserver(ad, { name: 'Blogpost Ad' })

    

Permitir side-effects

Los side-effects tienen mala fama, pero necesitamos side-effects. La clave es colocarlos donde correspondan.

Si queremos recibir una notificación cuando ocurra un evento de intersección, podemos permitir un callback en las opciones y ejecutarlo.

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

    

Si un callback no es suficiente, podemos ofrecer una pieza opcional de estado reactivo.

JS
const isIntersecting = ref<boolean>(false)

...

return {
    readonly(isIntersecting),
    ...
}

    

Luego, podemos watch el estado y manejar los side-effectss desde el componente.

JS
watch(isIntersecting, () => {
    // Manejar efectos secundarios aquí...
})

    

Resultado final

Aquí tienes el código completo del composable.

Juan Andrés Núñez
Juan Andrés Núñez
Ingeniero Frontend Senior. Especialista en Vue.js. Speaker. Docente profesional. Estoico.