Vue components don't have to just be about displaying information and user interaction. In this post, we'll show how to build a component whose main job is to simply fetch data for other components. We'll use props, events, and scoped-slots to tie the pieces together.

The project

This post is part of ongoing series, Building a PDF Viewer with Vue.js. The source code for this project is on Github at rossta/vue-pdfjs-demo. To see the source described in this post, checkout the branch.

Here's the latest project demo.

Catching up from last time

So far in this series, we have built a simple PDF viewer to render the pages of PDF document to <canvas> elements with Vue. We have also updated our components to fetch and render PDF pages lazily as they are scrolled into the viewport. For the next feature, we want to build a preview pane into the left-hand side of the viewer.

Preview of the preview

This preview pane will display the entire document (as smaller, clickable thumbnails), be independently-scrollable, and render PDF pages lazily, i.e., it will behave a lot like our current <PDFDocument>. First, we'd like to make some of our current code reusable.

The why

The Vue docs provide helpful examples for using mixins, custom directives, and more. My preferred approach for sharing component functionality is composition, which means extracting shared code into separate components. In this post, we'll be using composition to reuse data fetching.

Why composition? This topic deserves a separate post, but as a start, it's my preference. Borrowing from general object-oriented programming advice, I gravitate towards "composition over inheritance". Practically, in Vue, this means I'd like to think "component-first", before reaching for mixins or extends (these are basically forms of inheritance).

I also happen to agree with the drawbacks Dan Abramov enumerates in Mixins Considered Harmful; though the context for his post is React, most of his points are relevant to Vue as well.

As for this particular use case, we could reuse the data fetching code we wrote previously as a mixin, there's a clear problem with that approach. It would mean the components that make use of the mixin would fetch the same data independently (without extracting some other mechanism to share the data source)—which is potentially some wasted work. This may be desired for some applications, we'd prefer to only fetch the PDF page data once.

It's also worth noting that we're not currently using Vuex to manage application state in the project. It may be wise, as an alternative to what's described in this post, to introduce Vuex to fetch data by dispatching actions and triggering state mutations at the appropriate times. However, at this point, our data flow is fairly straightforward, top-to-bottom, which, in my opinion, favors the component-first approach. It's also simply a worthy exercise to consider data components.

Bird's eye view

Let's take a look at where we are and where we want to go. Prior to adding our feature, our component hierarchy looks like the following pseudocode:

<PDFDocument>
  <PDFPage />
  <PDFPage />
  <PDFPage />
  ...
</PDFDocument>

For our new preview feature, our preview and document components will live side-by-side.

<PDFPreview>
  <PDFThumbnail />
  <PDFThumbnail />
  <PDFThumbnail />
  ...
</PDFPreview>
<PDFDocument>
  <PDFPage />
  <PDFPage />
  <PDFPage />
  ...
</PDFDocument>

Our <PDFPreview> needs access to the same PDF data as our <PDFDocument>.

To achieve this, we're going to wrap both the <PDFPreview> and <PDFThumbnail> in another component, whose only responsibility will be to respond to events to request page data, which it will pass to its children as props. With this approach, there is only one data source shared by the two display components.

So our heirarchy will eventually look like this:

<PDFData> <!-- passes page data to children -->

  <PDFPreview> <!-- emits events to request more pages -->
    ...
  </PDFPreview>
  <PDFDocument> <!-- emits events to request more pages -->
    ...
  </PDFDocument>

</PDFData>

Here we will have decoupled the logic for batching and requesting page data over the wire from the interactions and events that will trigger that behavior. For our viewer, either our document or future preview components can trigger data fetching. The data component needs to know nothing about the scrolling behavior or the logic that determines when additional pages are needed.

Next we'll take a look at how this data component is constructed and how it will pass data to the child components.

Extracting the data component

Currently, the data fetching logic resides in our <PDFDocument> component. There is a method that encapsulates the logic for fetching pages in batches, watchers for responding to changes in the given url prop and pdf proxy object, and relevant data and computed properties for maintaining the state of PDF data. You can see the previous post for more info on the implementation details. We'll move this functionality to a new <PDFData> component:

// src/components/PDFData.vue

props: {
  url: {
    type: String,
    required: true,
  },
},

data() {
  return {
    pages: undefined,
    pages: [],
    cursor: 0,
    // ...
  };
},

methods: {
  fetchPages() {
    // fetches next batch and appends to this.pages
  },
},

computed: {
  pageCount() {
    return this.pdf ? this.pdf.numPages : 0;
  },
},

// ...

This component will be "renderless"* (almost), meaning it will delegate rendering to its children. We'll use scoped slots to pass the this.pages data to the preview and document components. The <PDFData> needs to nothing about its children, only that it will pass data to its named children, preview and document, in its own render function:

// src/components/PDFData.vue

render(h) {
  return h('div', [
    this.$scopedSlots.preview({
      pages: this.pages,
    }),
    this.$scopedSlots.document({
      pages: this.pages,
    }),
  ]);
},

// ...

*Technically, this component isn't "renderless"—it inserts an additional div as a root to its scoped slots children. Otherwise, the error Multiple root nodes returned from render function. Render function should return a single root node. is raised in the current version of Vue I'm using (2.5.16). The main point is that we can use components in our component hierarchy that add functionality but handoff display responsibility to its children.

Communicating with the children

In the <PDFViewer> we can use the slot attribute to render the children in the correct place and slot-scope to receive the pages property from the <PDFData> component. Though we haven't created the <PDFPreview> components, here's our template for the <PDFViewer> responsible for gluing everything together.

<!-- src/components/PDFViewer.vue -->

<template>
  <PDFData>
    <!-- At this point in the tutorial, PDFPreview
    doesn't exist, but this is where it will go. -->
    <PDFPreview
      slot="preview"
      slot-scope="{pages}"
      v-bind="{pages}"
      />

    <PDFDocument
      slot="document"
      slot-scope="{pages}"
      v-bind="{pages}"
      />
  </PDFData>
</template>

To trigger data fetching, the <PDFData> component will listen for the pages-fetch event. Since we're using a render function, we won't be able to use the template syntax for binding to events. Instead, we'll attach the event listener using this.$on in the created hook:

// src/components/PDFData.vue

created() {
  this.$on('pages-fetch', this.fetchPages);
},

// ...

Now we need to set up our <PDFDocument> to communicate with the <PDFData> component. We update <PDFDocument to accept pages as props now that it is now longer responsible for fetching this data. Its fetchPages method, called when the component mounts or during scrolling, we'll leave in place but change its implementation (now owned by its parent <PDFData> component) to simply emit the pages-fetch event, for which <PDFData> is listening.

// src/components/PDFDocument.vue

props: {
  pages: {
    type: Array,
    required: true,
  },
},

data() {
  return {
    // removed pages and pdf properties
    // ...
  };
},

methods: {
  fetchPages() {
    this.$emit('pages-fetch');
  },

  // ...
}

Wrapping up

That does it! We've extracted data fetching logic completely out of the <PDFDocument> into the <PDFData>. We've avoided the drawbacks of introducing mixins to share behavior. Our new data component will show up separately in the Vue dev tools extension for better debugging. The application is also easier to extend so we can now add new functionality, like the preview pane. We also have a nice alternative to Vuex, which would be a new dependency, to managing a portion of our application state.

In the next post, we'll look at extracting shared behavior so that both our preview and document components can be independently scrollable and either can trigger additional data-fetching when the scrolled to the bottom.

Share this post on Twitter

Part of the PDF Viewer series. Published on Jul 15, 2018