Building Renderless Components to Handle CRUD Operations in Vue.js

  You block advertising 😢
Would you like to buy me a ☕️ instead?

In episode 81 of the Full Stack Radio podcast Adam Wathan and Evan You talked about the possibility of utilizing the power of slot scopes to build components which sole purpose is to fetch data and provide the result via slot scope properties to the markup. In today’s article we’re going to take this into practice and we even go a little further: we’ll build “renderless components” to handle all CRUD operations in a reusable way.

Disclaimer: while working on this article I’ve found out that Adam has already implemented a basic version of this concept in his Advanced Vue Component Design course himself (check out Adams course, it’s definitely worth its money), but because in my article, I take the idea one step further, I decided that this article is still worth publishing.

As it is with almost all of my articles, you can find the full code on GitHub or take a look at the demo page hosted on Netlify.

Building our first renderless component

The term “renderless component” comes from the React world and, as far as I know, it was first used by Adam Wathan in conjunction with Vue.js components – it describes a component, which does not render any HTML itself but instead provides data and functionality to a component via a scoped slot. In this step, we’ll create a renderless component which fetches data from an API.

npm install --save axios

First of all, let’s install axios as a dependency, because axios makes it easier to handle requests to an API.

// src/components/DataList.js
import axios from 'axios';

export default {
  props: {
    baseUrl: {
      type: String,
      // The JSONPlaceholder API is a fake API
      // basically a Lorem Ipsum JSON API.
      default: 'https://jsonplaceholder.typicode.com',
    },
    endpoint: {
      type: String,
      required: true,
    },
    // Provide a filter to limit the
    // results of the API request.
    filter: {
      type: Object,
    },
  },
  data() {
    return {
      // Create a new axios instance.
      // See: https://github.com/axios/axios#creating-an-instance
      api: axios.create({ baseURL: this.baseUrl }),
      data: null,
      error: null,
      loading: false,
    };
  },
  watch: {
    // Load the data from the given endpoint
    // on initial rendering of the component and
    // every time the filter property changes.
    filter: {
      immediate: true,
      handler: 'load',
    },
  },
  methods: {
    // The `query` method will handle
    // different query types for us.
    async query(type, ...params) {
      // If we're currently loading content
      // we don't submit an additional request.
      if (this.loading) return;

      this.loading = true;
      try {
        const response = await this.api[type](...params);
        this.data = response.data;
        this.error = null;
        this.$emit('success', response);
      } catch (error) {
        this.data = null;
        this.error = error.response;
        this.$emit('error', error);
      }
      this.loading = false;
    },
    load() {
      return this.query('get', this.endpoint, { params: this.filter });
    },
  },
  render() {
    // Render the default scoped slot and
    // provide data and method properties
    // via the slot scope.
    return this.$scopedSlots.default({
      data: this.data,
      error: this.error,
      load: this.load,
      loading: this.loading,
    });
  },
};

The code above might look rather complex but I think it becomes more clear when we take a look at how to use the DataList component in the next example.

Declarative data fetching

The DataList component is basically a declarative way to fetch data from an API endpoint. We provide an API endpoint and we react to whichever data we receive in a declarative way.

<data-list endpoint="posts">
  <div slot-scope="{ data: posts, error, loading }">
    <span v-if="loading">Loading...</span>
    <span v-else-if="error">Error while fetching data!</span>
    <ul v-else>
      <li v-for="post in posts" :key="post.id">
        <h3>{{ post.title }}</h3>
        <p>{{ post.body }}</p>
      </li>
    </ul>
  </div>
</data-list>

In the code snippet above, you can see a basic example for how to use the DataList component to fetch a list of posts and render the data returned by the API endpoint. Displaying a loading state and error handling can be done very easily thanks to the properties provided by the DataList component.

Pagination

Let’s take a look at a more advanced example. With some small tweaks to the code from the previous example, we’re able to implement a simple pagination.

<data-list
  endpoint="posts"
  :filter="{ page }"
>
  <div slot-scope="{ data: posts, error, loading }">
    <span v-if="loading">Loading...</span>
    <span v-else-if="error">Error while fetching data!</span>
    <ul v-else>
      <li v-for="post in posts" :key="post.id">
        <h3>{{ post.title }}</h3>
        <p>{{ post.body }}</p>
      </li>
    </ul>
    <button @click="page = 1">1</button>
    <button @click="page = 2">2</button>
    <button @click="page = 3">3</button>
  </div>
</data-list>

In the example above, you can see that we now pass a filter object as a property to the <data-list> component instance. The buttons at the bottom are our pagination controls, by setting the value of page every time a user clicks on a button, the filter is updated and the DataList component triggers the load() method.


Do you want to learn more about advanced Vue.js techniques?

Register for the Newsletter of my upcoming book: Advanced Vue.js Application Architecture.


Declarative CRUD operations

So far we’ve only touched the R in CRUD. Let’s take a look at how we can make it possible to also create, update and delete data in a declarative way.

Refactoring

Before we get started with implementing a component to handle CRUD operations, we should refactor our code first in order to being able to reuse parts of it for our new component.

-import axios from 'axios';
+import queryMixin from './mixins/query';
 
 export default {
+  mixins: [queryMixin],
   props: {
-    baseUrl: {
-      type: String,
-      // The JSONPlaceholder API is a fake API
-      // basically a Lorem Ipsum JSON API.
-      default: `https://jsonplaceholder.typicode.com`,
-    },
-    endpoint: {
-      type: String,
-      required: true,
-    },
     // Provide a filter to limit the
     // results of the API request.
     filter: {
       type: Object,
     },
   },
-  data() {
-    return {
-      // Create a new axios instance.
-      // See: https://github.com/axios/axios#creating-an-instance
-      api: axios.create({ baseURL: this.baseUrl }),
-      data: null,
-      error: null,
-      loading: false,
-    };
-  },
   // ...
   methods: {
-    // The `query` method will handle
-    // different query types for us.
-    async query(type, ...params) {
-      // If we're currently loading content
-      // we don't submit an additional request.
-      if (this.loading) return;
-
-      this.loading = true;
-      try {
-        const response = await this.api[type](...params);
-        this.data = response.data;
-        this.error = null;
-        this.$emit('success', response);
-      } catch (error) {
-        this.data = null;
-        this.error = error.response;
-        this.$emit('error', error);
-      }
-      this.loading = false;
     },
     load() {
       return this.query('get', this.endpoint, { params: this.filter });
     },
   },
   // ...
 };

We can remove a lot of functionality from the DataList component and move it into a generic query mixin which we can reuse in other components.

// src/components/mixins/query.js
import axios from 'axios';

export default {
  props: {
    baseUrl: {
      type: String,
      // The JSONPlaceholder API is a fake API
      // basically a Lorem Ipsum JSON API.
      default: `https://jsonplaceholder.typicode.com`,
    },
    endpoint: {
      type: String,
      required: true,
    },
  },
  data() {
    return {
      // Create a new axios instance.
      // See: https://github.com/axios/axios#creating-an-instance
      api: axios.create({ baseURL: this.baseUrl }),
      data: null,
      error: null,
      loading: false,
    };
  },
  methods: {
    // The `query` method will handle
    // different query types for us.
    async query(type, ...params) {
      // If we're currently loading content
      // we don't submit an additional request.
      if (this.loading) return;

      this.loading = true;
      try {
        const response = await this.api[type](...params);
        this.data = response.data;
        this.error = null;
        this.$emit(`success`, response);
      } catch (error) {
        this.data = null;
        this.error = error.response;
        this.$emit(`error`, error);
      }
      this.loading = false;
    },
  },
};

The DataModel component

The create, update and delete operations have one thing in common: they’re dealing with a single entity. The DataList component is meant to handle lists, let’s create a new component to handle single records.

// src/components/DataModel.js
import queryMixin from './mixins/query';

export default {
  mixins: [queryMixin],
  props: {
    // Make it possible to (optinally) provide
    // initial data via an `entity` property.
    entity: {
      type: Object,
    },
    // By providing an initial ID, we can link
    // the model instance with a specific record.
    id: {
      type: [Number, String],
    },
  },
  data() {
    return {
      data: this.entity || null,
    };
  },
  created() {
    // If an ID but no initial data entity
    // was provided, the specified record
    // is fetched from the API.
    if (this.id && !this.data) this.find();
  },
  methods: {
    create(data) {
      return this.query('post', this.endpoint, data);
    },
    destroy() {
      return this.query('delete', `${this.endpoint}/${this.id}`);
    },
    find() {
      return this.query('get', `${this.endpoint}/${this.id}`);
    },
    update(data) {
      return this.query('patch', `${this.endpoint}/${this.id}`, data);
    },
  },
  render() {
    return this.$scopedSlots.default({
      create: this.create,
      data: this.data,
      destroy: this.destroy,
      loading: this.loading,
      update: this.update,
    });
  },
};

Create

Let’s take a look how to use the DataModel component to create a new record.

<data-model endpoint="posts">
  <div slot-scope="{ data: post, loading, create }">
    <span v-if="loading">Loading...</span>
    <template v-if="post">
      <h3>{{ post.title }}</h3>
      <p>{{ post.body }}</p>
    </template>

    <form @submit.prevent="create(newPost);">
      <label>
        Title: <input v-model="newPost.title">
      </label>
      <label>
        Body: <input v-model="newPost.body">
      </label>
      <button :disabled="loading">
        <template v-if="loading">Loading...</template>
        <template v-else>Create</template>
      </button>
    </form>
  </div>
</data-model>

In the example code snippet above, you can see, that we’re not passing an entity or id property to the <data-model> component. Therefore, initially, no API call is made and no resource is loaded. But we can create a new post resource, by calling the create method provided by the DataModel via the slot-scope. As soon as the API call was successful, the DataModel instance is linked to the newly created post resource returned by the API and we render the post.title and the post.body above the form.

Update

The markup for updating a resource, looks almost identical to the markup for creating a new resource you’ve seen above, but this time we provide an ID property to the DataModel.

<data-model endpoint="posts" :id="1">
  <div slot-scope="{ data: post, loading, update }">
    <span v-if="loading">Loading...</span>
    <template v-if="post">
      <h3>{{ post.title }}</h3>
      <p>{{ post.body }}</p>
    </template>

    <form @submit.prevent="update(post);">
      <label>
        Title: <input v-model="post.title">
      </label>
      <label>
        Body: <input v-model="post.body">
      </label>
      <button :disabled="loading">
        <template v-if="loading">Loading...</template>
        <template v-else>Update</template>
      </button>
    </form>
  </div>
</data-model>

Delete

Basically, the destroy method, does almost the same as the create and update methods but after successfully deleting a resource, we don’t have any data anymore to render, instead we can listen to the success event of the DataModel instance to react in some way to successful deletion of the resource.

<data-model endpoint="posts" :id="1" @success="deleted = true">
  <div slot-scope="{ delete }">
    <p v-if="deleted">
      The post was successfully deleted.
    </p>
    <button :disabled="loading">
      <template v-if="loading">Loading...</template>
      <template v-else>Delete</template>
    </button>
  </div>
</data-model>

Combining DataList and DataModel

Now let’s imagine the following scenario: you want to render a list of posts and additionally you want to render a Delete button for every post. We can combine the DataList and DataModel components to deal with this requirements.

<data-list endpoint="posts">
  <div slot-scope="{ data: posts, error, load, loading }">
    <span v-if="loading">Loading...</span>
    <span v-else-if="error">Error while fetching data!</span>
    <ul v-else>
      <li v-for="post in posts" :key="post.id">
        <data-model
          :entity="post"
          :id="post.id"
          endpoint="posts"
          @success="load"
        >
          <div slot-scope="{ destroy, loading }">
            <h3>{{ post.title }}</h3>
            <p>{{ post.body }}</p>
            <button
              :disabled="loading"
              @click="destroy"
            >
              <template v-if="loading">Loading...</template>
              <template v-else>Delete</template>
            </button>
          </div>
        </data-model>
      </li>
    </ul>
  </div>
</data-list>

In the example above, you can see that we can nest DataList and DataModel components. By passing a post object as an entity to the DataModel component, no API call is made initially, but we’re able to run CRUD operations on the model. A post can be deleted by clicking on the Delete button, the @success event handler on the <data-model> is triggered after a successful request, so if the post was deleted successfully, we trigger the load() method provided by the DataList component, which triggers a reload of the data provided by the DataList (which now shouldn’t contain the deleted post anymore).

Wrapping it up

The power of slot-scope makes it possible to build generic wrapper components to provide all kinds of data or functionality to the wrapped component. Handling CRUD operations is only one of the plentiful possibilities. But I think it is one of the most useful ways of how to utilize renderless components.

The examples provided in this article only scratch the surface of what is possible to achieve with the DataList and DataModel components. If you’re interested in seeing additional examples, you can check out the GitHub repository or take a look at the demo page hosted on Netlify.


Do you want to learn how to build advanced Vue.js applications?

Register for the Newsletter of my upcoming book: Advanced Vue.js Application Architecture.



Do you enjoy reading my blog?

You can buy me a ☕️ on Ko-fi!

☕️ Support Me on Ko-fi