Writing Abstract Components With Vue.js

Joshua Bemenderfer

Vue components are great, right? They encapsulate the view and behavior of your app into nice little composable pieces. If you need a little extra functionality on them, just attach directives! Thing is, directives are fairly inflexible and can’t do everything. Directives can’t (easily) emit events, for example. Well, this being Vue, of course there’s a solution. Abstract components!

Abstract components are like normal components, except they don’t render anything to the DOM. They just add extra behavior to existing ones. You might be familiar with abstract components from Vue’s built-in ones, such as <transition>, <component>, and <slot>.

A great use-case for abstract components is tracking when an element enters the viewport with IntersectionObserver. Let’s take a look at implementing a simple abstract component to handle that here.

If you'd like a proper production-ready implementation of this, take a look at vue-intersect, which this tutorial is based on.

Getting Started

First we’ll create a quick abstract component that simply renders its contents. To accomplish this, we’ll take a quick dip into render functions.

IntersectionObserver.vue

export default {
   // Enables an abstract component in Vue.
   // This property is undocumented and may change at any time,
   // but your component should work without it.
  abstract: true,
  // Yay, render functions!
  render() {
    // Without using a wrapper component, we can only render one child component.
    try {
      return this.$slots.default[0];
    } catch (e) {
      throw new Error('IntersectionObserver.vue can only render one, and exactly one child component.');
    }

    return null;
  }
}

Congratulations! You now have an abstract component that, well, does nothing! It just renders its children.

Adding IntersectionObserver

Okay, now let’s stick in the logic for IntersectionObserver.

IntersectionObserver isn't supported natively in IE or Safari, so you might want to go grab a polyfill for it.

IntersectionObserver.vue

export default {
   // Enables an abstract component in Vue.
   // This property is undocumented and may change at any time,
   // but your component should work without it.
  abstract: true,
  // Yay, render functions!
  render() {
    // Without using a wrapper component, we can only render one child component.
    try {
      return this.$slots.default[0];
    } catch (e) {
      throw new Error('IntersectionObserver.vue can only render one, and exactly one child component.');
    }

    return null;
  },

  mounted () {
    // There's no real need to declare observer as a data property,
    // since it doesn't need to be reactive.

    this.observer = new IntersectionObserver((entries) => {
      this.$emit(entries[0].isIntersecting ? 'intersect-enter' : 'intersect-leave', [entries[0]]);
    });

    // You have to wait for the next tick so that the child element has been rendered.
    this.$nextTick(() => {
      this.observer.observe(this.$slots.default[0].elm);
    });
  }
}

Alright, so now we have an abstract component we can use like this:

<intersection-observer @intersect-enter="handleEnter" @intersect-leave="handleLeave">
  <my-honest-to-goodness-component></my-honest-to-goodness-component>
</intersection-observer>

We’re not done yet though…

Finishing Up

We need to make sure not to leave any dangling IntersectionObservers when the component is removed from the DOM, so let’s fix that real quick now.

IntersectionObserver.vue

export default {
   // Enables an abstract component in Vue.
   // This property is undocumented and may change at any time,
   // but your component should work without it.
  abstract: true,
  // Yay, render functions!
  render() {
    // Without using a wrapper component, we can only render one child component.
    try {
      return this.$slots.default[0];
    } catch (e) {
      throw new Error('IntersectionObserver.vue can only render one, and exactly one child component.');
    }

    return null;
  },

  mounted() {
    // There's no real need to declare observer as a data property,
    // since it doesn't need to be reactive.

    this.observer = new IntersectionObserver((entries) => {
      this.$emit(entries[0].isIntersecting ? 'intersect-enter' : 'intersect-leave', [entries[0]]);
    });

    // You have to wait for the next tick so that the child element has been rendered.
    this.$nextTick(() => {
      this.observer.observe(this.$slots.default[0].elm);
    });
  },

  destroyed() {
    // Why did the W3C choose "disconnect" as the method anyway?
    this.observer.disconnect();
  }
}

And just for bonus points, let’s make the observer threshold configurable with props.

IntersectionObserver.vue

export default {
   // Enables an abstract component in Vue.
   // This property is undocumented and may change at any time,
   // but your component should work without it.
  abstract: true,

  // Props work just fine in abstract components!
  props: {
    threshold: {
      type: Array
    }
  },

  // Yay, render functions!
  render() {
    // Without using a wrapper component, we can only render one child component.
    try {
      return this.$slots.default[0];
    } catch (e) {
      throw new Error('IntersectionObserver.vue can only render one, and exactly one child component.');
    }

    return null;
  },

  mounted() {
    // There's no real need to declare observer as a data property,
    // since it doesn't need to be reactive.

    this.observer = new IntersectionObserver((entries) => {
      this.$emit(entries[0].isIntersecting ? 'intersect-enter' : 'intersect-leave', [entries[0]]);
    }, {
      threshold: this.threshold || 0
    });

    // You have to wait for the next tick so that the child element has been rendered.
    this.$nextTick(() => {
      this.observer.observe(this.$slots.default[0].elm);
    });
  },

  destroyed() {
    // Why did the W3C choose "disconnect" as the method anyway?
    this.observer.disconnect();
  }
}

The final usage looks like this:

<intersection-observer @intersect-enter="handleEnter" @intersect-leave="handleLeave" :threshold="[0, 0.5, 1]">
  <my-honest-to-goodness-component></my-honest-to-goodness-component>
</intersection-observer>

There you go! Your first abstract component.

Big thanks to Thomas Kjærgaard / Heavyy for the initial implementation and idea!

  Tweet It
✖ Clear

🕵 Search Results

🔎 Searching...