VueJS pagination component to paginate anything in Laravel

When it comes to listing long length data with pagination, laravel has the simplest solution to achieve it, you just call paginate() method on the Eloquent query builder and laravel will do the heavy lifting to give you paginated data. Now laravel and Vue are perfect match since Vue is the frontend framework of choice in Laravel community. In this post, we will build a component to paginate data easily from frontend using Vue.js and Laravel.

Let’s take simple steps to build the pagination, we will use Laravel paginate method on posts table. Now, setup the Laravel 5.5 app and run php artisan make:model Post -mrf this will create Model, Factory, Migration and a Resource controller in one go.

Model created successfully.
Factory created successfully.
Created Migration: 2017_10_22_043035_create_posts_table
Controller created successfully.

Setup the Data

We have all the files generated let’s fill up the schema and factory so we can seed dummy data.

Post Migration

Post model will have title, body and a cover_pic.

Schema::create('posts', function (Blueprint $table) {
    $table->increments('id');
    $table->string('title');
    $table->text('body');
    $table->string('cover_pic')->nullable();
    $table->timestamps();
});

Here is the factory for a post.

$factory->define(App\Post::class, function (Faker $faker) {
    return [
        'title' => $faker->sentence,
        'body' => $faker->realText(rand(100, 676)),
        'cover_pic' => $faker->imageUrl()
    ];
});

Now create some posts, I am adding this in database/seeds/DatabaseSeeder.php to seed it easily.

factory(App\Post::class, 40)->create();
$this->command->info('seeding done...');

Run php artisan migrate --seed, we have the data in the table now we need a route for posts listing, let’s create the resource route in routes/web.php.

Resource Route and Controller

Route::resource('posts', 'PostController');

I am adding a simple pagination in PostController@index method.

public function index()
{
    return App\Post::simplePaginate();
}

Run the php artisan serve and visit http://127.0.0.1:8000/posts, it should list out paginated posts as JSON.

This will give us 40 post which is pretty good to test the pagination, now the setup is out of the way, let’s build the main Vue component to list out the post and paginate them.

SimplePaginate Vue Component

This is the main part of this post, if you look at the paginated response you find that it schema looks something like this:

{
   "current_page":1,
   "data":[
      {
         "id":1,
         "title":"Et consectetur minima repellendus delectus et unde ab dolor.",
         "body":"The great question is, what?' The great question certainly was, what? Alice looked all round her at the window, and on it (as she had nibbled some more bread-and-butter--' 'But what happens when one eats cake, but Alice had no idea how confusing it is all the things I used to say.' 'So he did, so he with his head!' or 'Off with their heads down and make one repeat lessons!' thought Alice; 'but a grin without a great hurry. 'You did!' said the Queen. An invitation from the trees had a.",
         "cover_pic":"https://lorempixel.com/640/480/?89123",
         "created_at":"2017-10-22 13:36:44",
         "updated_at":"2017-10-22 13:36:44"
      },
      {
         "id":2,
         "title":"Veniam et voluptas quia fugiat aliquid reiciendis laboriosam.",
         "body":"Alice. 'You are,' said the Mock Turtle, 'Drive on, old fellow! Don't be all day to such stuff? Be off, or I'll kick you down stairs!' 'That is not said right,' said the Duchess, the Duchess! Oh! won't she be savage if I've been changed in the sea, though you mayn't believe it--' 'I never could abide figures!' And with that she was quite pleased to have lessons to learn! No, I've made up my mind about it; and while she was exactly three inches high). 'But I'm not looking for it, he was in the air, and came flying down upon her: she gave one sharp kick, and waited to see that the hedgehog had unrolled itself, and was in.",
         "cover_pic":"https://lorempixel.com/640/480/?16842",
         "created_at":"2017-10-22 13:36:44",
         "updated_at":"2017-10-22 13:36:44"
      }
   ],
   "first_page_url":"http://localhost:8000/posts?page=1",
   "from":1,
   "next_page_url":"http://localhost:8000/posts?page=2",
   "path":"http://localhost:8000/posts",
   "per_page":2,
   "prev_page_url":null,
   "to":2
}

The key part is the links provided for next page if there are more pages next_page_url will have an URL for it, and if there is nothing left it will be set to null. We will use next_page_url to show the load more button and fetch the next page in our component.

Create a new Vue component in resources/assets/js/components/SimplePagination.vue and add following code:

<template>
  <div :class="{'loading': loading}" class="pager-data-wrapper">

      <article class="pager-result" v-for="item in result" :key="item.id">
        <figure>
            <img :src="item.cover_pic" alt="">
        </figure>
        <section>
            <h3><a href="">{{ item.id}} {{ item.title }}</a></h3>
            <footer>{{ item.created_at }}</footer>
        </section>
      </article>

      <div class="pager-link text-center">
          <button
            @click.prevent="loadMore"
            class="btn btn-block btn-primary"
            :class="{loading: loading}"
            :disabled="loading || !nextPageUrl">
                {{ loading ? 'Loading...' : moreBtnText }}
          </button>
      </div>
  </div>
</template>

<script>
export default {
  props: {
      url: {
          required: true
      },
      moreBtnText: {
          type: String,
          default: 'Load More...'
      }
  },
  data() {
      return {
          result: [],
          loading: true,          
          nextPageUrl: null,
      }
  },
  created() {
      // load the data initially 
      this.fetchData();
  },
  methods: {
      fetchData(url) {
            let vm = this;
            let endpoint = url ? url : this.url;

            // show loader
            vm.loading = true;

            // fetch the data from passed url property
            axios.get(endpoint).then((res) => {
                // hide the loader
                vm.loading = false;

                // Assign returend data
                if( url ) {
                    // push next page into result
                    _.forEach(res.data.data, (item) => vm.result.push(item))
                } else {
                    // add first page
                    vm.result = res.data.data;
                }
                
                // assigne next page url
                vm.nextPageUrl = res.data.next_page_url;
            }).catch((err) => {
                vm.loading = false;
            })
      },
      nextPage() {
          this.fetchData(this.nextPageUrl)
      }
  }
}
</script>

This our base component, which fetches the first set of posts from /posts endpoint and adds returned data into result data property. It also stores nextPageUrl which is used in nextPage() method to get next page data.

This is good, but we don’t want to find our self-creating separate component to list different types of data, let’s say a paginated comments, photos etc. Now we will make this component re-usable so we can use the same component to paginate almost anything.

Scope slot for Pagination

We need to change the <article> v-for section in the template to a scoped slot, idea is to pass the result as a scope parameter which will give us complete freedom on the list rendering part. You can checkout Practical use of Components and Mixins in Vue JS to know more about scoped slot and component.

Edit components template and replace article tag with this:

<slot :result="result">
    <div class="text-center alert-info pb-3">
        <span v-if="!loading">
            {{ result.length }} items found. render it using v-for="item in result"
        </span>
        <span v-if="loading">
            Loading...
        </span>
    </div>
</slot>

And now we have a scoped slot, by default we are giving some info about a number of the result if there is no slot template provided. To render out same posts using this we just need to do pass our template into this default slot:

<simple-pagination url="/posts">
    <template scope="data">
        <article class="pager-result" v-for="item in data.result" :key="item.id">
            <figure>
                <img :src="item.cover_pic" alt="">
            </figure>
            <section>
                <h3><a href="">@{{ item.id}} @{{ item.title }}</a></h3>
                <footer>@{{ item.created_at }}</footer>
            </section>
        </article>
    </template>
</simple-pagination>

This enables us to use this component for any type of paginated data. You just pass the URL porp into the component and override the template by passing into default slot.

Chunking data

In any case, we need to show grid on n number of item per row, achieve it we have two options, use CSS or you can chunk the data into pieces and loop to render it. Now we need this flexibility in our simple paginator, let’s use lodash (which comes included in laravel by default) chunk method to split the result. We will use a prop for chunked to check if chunking required. This way you will have complete control over item per row by passing just an argument.

It’s time to extract our result getter into a method which will check the chunked prop of a component, if it has a number it will chunk the data else it will return the result as is:

<template>
  <div :class="{'loading': loading}" class="pager-data-wrapper">
      <slot :result="getResult()">
        ...
      </slot>
</template>
<script>
export default {
    props: {
        ...
        chunked: {
            type: Number,
            default: 0
        },
    },
    methods: {
        ...
        getResult() {
            return (this.chunked > 0) ? _.chunk(this.result, this.chunked) : this.result;
        }
    }
}
</script>

Now you can use same use this chunked data as this:

<simple-pagination url="/posts" :chunked="2">
    <template scope="data">
        <div class="row" v-for="row in data.result">
            <div class="col-md-6" v-for="item in row" :key="item.id">
                <article>
                    <figure>
                        <img :src="item.cover_pic" alt="">
                    </figure>
                    <section>
                        <h3><a href="">@{{ item.id}} @{{ item.title }}</a></h3>
                        <footer>@{{ item.created_at }}</footer>
                    </section>
                </article>
            </div>        
        </div>
    </template>
</simple-pagination>

What about if you want to add some data like prepend or append to this paginated data, let’s see how we can solve this.

Prepend and append data into pagination

When it comes to injecting data into a component Vue offers props, since our result array is inside pagination component we will need some another way to inject data in. It can be helpful in the situation like when you add a new post, instead of fetching it from the server you can directly append it.

To enable this feature we will be adding two new properties on component, prepend and append. As name suggest you should bind prepend to some data if you want to add it in the beginning of post-result array and append is to push it at the end of result array.

export default {
    props: {
        ...
        prepend: {
            default: null
        },
        apepend: {
            default: null
        }
    },
    data() {
        return {
            ...
            result: [],
            prependData: null,
            appendData: null
        }
    },
    watch: {
        prepend(newVal) {
            if( newVal ) {
                this.prependData = newVal;
                this.result.unshift(newVal);
            }

            this.prependData = null
        },

        append(newVal) {
            if( newVal ) {
                this.appendData = newVal;
                this.result.push(newVal);
            }

            this.appendData = null
        }
    }
}

Passing append property with an object is going to work only first time when component initializes, but it won’t update when you dynamically change its value, to fix it we have used watch method, which will push or unshift into result array whenever prepend or append props values changes.

Let’s make the append data work.

<div class="row pb-3">
    <div class="col-md-6">
    </div>
    <div class="col-md-6 text-right">
        <a href="#" @click.prevent="addPost" class="btn btn-primary">Add Post</a>
    </div>
</div>

<simple-pagination url="/posts" :chunked="2" :prepend="prepend">
...
</simple-pagination>

Now let’s add the handler for addPost() on the root instance, in a real app it will be in your parent component.

const app = new Vue({
    el: '#app',
    data: {
        prepend: null,
    },
    methods: {
        addPost() {
            this.prepend = this.dummyPost()
        },
        dummyPost() {
            return {
                title: 'New Post ' + Math.random() * 1,
                body: 'Body of the last post',
                cover_pic: 'https://lorempixel.com/640/480/?89123',
                created_at: 'Oct 21, 2017'
            }
        }
    }
});

If you click on Add Post button a new post will be appended to the result array and will be shown on the list.

Next and Previous pagination style

Load more is good for simple data and if you have thousands of items and you are loading using a load more to add all the items on the same page, it can hurt browser performance since all the elements will be on page and Vue needs to keep track of reactivity and change detection. To avoid it we will now add pagination style option, where you can pass the pager-type prop which will be either paged or single , by default it will be single mode, which will have a load more button like we have currently and paged will change the load more to Next and Prev button style pagination.

<template>
    <div :class="{'loading': loading}" class="pager-data-wrapper">
    ...

    <div class="pager-link text-center">
        <div v-if="pagerType == 'paged'">
            <a 
            v-show="result.length"
            :disabled="!prevPageUrl || loading" 
            :href="prevPageUrl" 
            @click.prevent="prevPage" 
            :class="{loading: loading}"
            class="btn btn-primary">Prev</a>
            
            <a 
            :disabled="!nextPageUrl || loading" 
            :href="nextPageUrl" 
            @click.prevent="nextPage" 
            :class="{loading: loading}"                
            class="btn btn-primary">Next</a>
        </div>
        
        <button
        v-if="pagerType == 'single'"
        @click.prevent="nextPage"
        class="btn btn-block btn-primary"
        :class="{loading: loading}"
        :disabled="loading || !nextPageUrl">
            {{ loading ? 'Loading...' : moreBtnText }}
        </button>
    </div>  
    </div>
</template>
<script>
    export default {
        props: {
            ...
            pagerType: {
                default: 'single'
            }
        },
        data() {
            return {
                ...
                result: [],
                prevPageUrl: null,
            }
        },
        methods: {
            ...
            fetchData(url) {
                let vm = this;
                let endpoint = url ? url : this.url;

                // show loader
                vm.loading = true;

                // fetch the data from passed url property
                axios.get(endpoint).then((res) => {
                    // hide the loader
                    vm.loading = false;

                    // Assign returend data
                    if( url && vm.pagerType == 'single' ) {
                        // push next page into result
                        _.forEach(res.data.data, (item) => vm.result.push(item))
                    } else {
                        // add first page
                        vm.result = res.data.data;
                    }
                    
                    // assigne next and prev page url
                    vm.nextPageUrl = res.data.next_page_url;
                    vm.prevPageUrl = res.data.prev_page_url;
                }).catch((err) => {
                    vm.loading = false;
                })
            },
            prevPage() {
                this.fetchData(this.prevPageUrl)
            },

            nextPage() {
                this.fetchData(this.nextPageUrl)
            }
        }
    }
</script>

Now your simple pagination can be used in two ways, to use paged next previous one just pass the attribute like this.

<simple-pagination url="/posts" pager-type="paged">
...
</simple-pagination>

With that, we have completed our pagination component.

Conclusion

I hope you enjoyed and learned something new, this is a perfect pagination component which you can use in your project, if you want you can extract the data format logic to support any type of paginated dataset also you can add some props to pass the classes for button and data wrapper if you wanted, let me know in the comments if you have any question.

Source Code