// Tutorial //

Writing Abstract Components with Vue.js

Published on August 27, 2017
Default avatar
By Joshua Bemenderfer
Developer and author at DigitalOcean.
Writing Abstract Components with Vue.js

While we believe that this content benefits our community, we have not yet thoroughly reviewed it. If you have any suggestions for improvements, please let us know by clicking the “report an issue“ button at the bottom of the tutorial.

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!


Want to learn more? Join the DigitalOcean Community!

Join our DigitalOcean community of over a million developers for free! Get help and share knowledge in our Questions & Answers section, find tutorials and tools that will help you grow as a developer and scale your project or business, and subscribe to topics of interest.

Sign up
About the authors
Default avatar
Developer and author at DigitalOcean.

Still looking for an answer?

Was this helpful?
Leave a comment

This textbox defaults to using Markdown to format your answer.

You can type !ref in this text area to quickly search our full set of tutorials, documentation & marketplace offerings and insert the link!