VueJS Bootstrap Pagination Component

Click to share! ⬇️

VueJS Bootstrap Pagination Component

In this tutorial we will look at porting the built in Laravel pagination features into a full blown Vue paginator component which uses Bootstrap for styling. Along the way, we’ll introduce a few new concepts like tapping into the created() life cycle hook as well as setting up a watch: life cycle hook. Our new paginator will be a child component of the Replies.vue component, so we’ll need to also set up a lot of communication between these components using props, events, and event listeners. Let’s jump into creating a brand new Vue Paginator now.


Paginator Uses Ajax

In the standard Laravel way of doing things, it is ridiculously easy to set up vue pagination. All we have to do is render a blade view, and pass thru an instance of the paginator using code like we see here.


public function show($channel, Thread $thread)
{
    return view('threads.show', [
        'thread' => $thread,
        'replies' => $thread->replies()->paginate(10)
    ]);
}

It turns out, the threads/show.blade.php is not even using this anymore however since we create our <replies> component to use. So as it stands now, the snippet below is expecting to receive the collection of replies into the data binding which it can make use of from there.

<replies :data="{{ $thread->replies }}" @added="repliesCount++" @removed="repliesCount--"></replies>

Instead of this, we will make the <replies> component fetch its own data as needed via an ajax call. So we will remove that data binding like so.

<replies @added="repliesCount++" @removed="repliesCount--"></replies>

With the data binding in the template removed, this means props: ['data'] can be removed from Replies.vue as well. To start, that means we can just set up items as an empty array in the data() of Replies.vue.


data() {
    return {
        items: [],
        endpoint: location.pathname + '/replies'
    }
},

The created() hook

Now since the replies data is no longer passed to the component, we need to make that component responsible for fetching it’s own data on its own. To do this, we can use the created() hook in Vue. It almost works like a constructor in the sense that anything in it runs immediately upon creation. In this case, we want that component to make an ajax call to fetch the data it needs when it is created. This might be stubbed out like so.


<script>
    import Reply from './Reply.vue';
    import NewReply from './NewReply.vue';

    export default {
        components: {Reply, NewReply},

        data() {
            return {
                items: [],
                endpoint: location.pathname + '/replies'
            }
        },

        created() {
            this.fetch();
        },

        methods: {
            fetch() {
                axios.get(this.url)
                    .then(this.refresh);
            },

            refresh() {

            },

            add(reply) {
                this.items.push(reply);

                this.$emit('added');
            },

            remove(index) {
                this.items.splice(index, 1);

                this.$emit('removed');

                flash('Reply was deleted!');
            }
        }
    }
</script>

What the highlighted code indicates is that when this component is created, make an ajax request for the data it needs. When that completes, refresh the data within this component.


The Laravel API Endpoint

Ok, so the component is going to be making ajax requests for the data it needs. First we need a route.


<?php

Route::get('/', function () {
    return view('welcome');
});

Auth::routes();

Route::get('/home', 'HomeController@index')->name('home');

Route::get('/threads', 'ThreadsController@index');
Route::get('/threads/create', 'ThreadsController@create');
Route::get('/threads/{channel}/{thread}', 'ThreadsController@show');
Route::delete('/threads/{channel}/{thread}', 'ThreadsController@destroy');
Route::post('/threads', 'ThreadsController@store');
Route::get('/threads/{channel}', 'ThreadsController@index');
Route::get('/threads/{channel}/{thread}/replies', 'RepliesController@index');
Route::post('/threads/{channel}/{thread}/replies', 'RepliesController@store');
Route::patch('/replies/{reply}', 'RepliesController@update');
Route::delete('/replies/{reply}', 'RepliesController@destroy');
Route::post('/replies/{reply}/favorites', 'FavoritesController@store');
Route::delete('/replies/{reply}/favorites', 'FavoritesController@destroy');
Route::get('/profiles/{user}', 'ProfilesController@show')->name('profile');

Now we need an index() method in the RepliesController. We’ll also update the auth middleware to be less restrictive for the api. That little index() method is making use of paginate() but we are paginating on every single item to make it very obvious when we are building the component to see how it works.


<?php

namespace App\Http\Controllers;

use App\Reply;
use App\Thread;
use Illuminate\Http\Request;

class RepliesController extends Controller
{
    public function __construct()
    {
        $this->middleware('auth', ['except' => 'index']);
    }

    public function index($channelId, Thread $thread)
    {
        return $thread->replies()->paginate(1);
    }

    public function store($channelId, Thread $thread)
    {
        $this->validate(request(), [
            'body' => 'required'
        ]);

        $reply = $thread->addReply([
            'body' => request('body'),
            'user_id' => auth()->id()
        ]);

        if (request()->expectsJson()) {
            return $reply->load('owner');
        }

        return back()->with('flash', 'Your reply was published!');
    }

    public function update(Reply $reply)
    {
        $this->authorize('update', $reply);

        $reply->update(request(['body']));
    }

    public function destroy(Reply $reply)
    {
        $this->authorize('update', $reply);

        $reply->delete();

        //  if there is an ajax request, do not redirect
        if (request()->expectsJson()) {
            return response(['status' => 'Reply deleted']);
        }

        return back();
    }
}

Set Up The URL In Vue

In Replies.vue, we can add a url() method to figure out what the endpoint will be to send the ajax request to. We’re going to make use of location.pathname in JavaScript to help with this. Now, the fetch() method leans on the url() method to return the endpoint we want. In the refresh() method, we will just log out the data we get back from our new endpoint to see where we are at.


fetch() {
    axios.get(this.url())
        .then(this.refresh);
},

url() {
    return `${location.pathname}/replies`;
},

refresh(response) {
    console.log(response);
},

Now, let’s visit a thread page that we know has a few replies. In our case that is http://forum.io/threads/quo/53. Note we are getting a response from the server, and that we need to drill down on the data object to see the data we want.
console-log-ajax-response

This is a good case for ES6 Object Destructuring. If we update the refresh() method, we get access to the data straight away with no need for drilling down.


refresh({data}) {
    console.log(data);
},

ajax response destructuring

Even with this destructuring however, we’ll still need to drill down a bit to see the relevant data for the currently paginated set. Make sense?
paginated set


Saving The Ajax Response Data

There are actually two sets of data we are concerned with here. The first is the data about the paginated set. What page are we one, what is the current page, etc… The next set of data we need are the actually replies themselves so we can render them in the component. The dataSet variable will represent the first, and the items variable will represent the second. So we modify the refresh() method to stop logging the response to the console, and now actually populate the component itself with the information it needs in the data() section.


data() {
    return {
        dataSet: false,
        items: [],
        endpoint: location.pathname + '/replies'
    }
},

created() {
    this.fetch();
},

methods: {
    fetch() {
        axios.get(this.url())
            .then(this.refresh);
    },

    url() {
        return `${location.pathname}/replies`;
    },

    refresh({data}) {
        this.dataSet = data;
        this.items = data.data;
    },

At this point the response data from the server is now being used to populate the component so that it can use that data to render on the page. Here, we see the data contained in the <Replies> component. In addition, the component itself is also now rendering on the page. Very cool!


Cleaning up a Vue Component with a Mixin

Mixins can be thought of as re usable chunks of code that can be imported as needed in a given file. They work in a similar fashion as a Trait does in PHP. We can actually clean up our Replies.vue component a bit by making use of a mixin. First we’ll create a directory to store our mixins, and add a Collection.js file.
mixins storage directory

The Collection.js mixin now has this script code.

export default {
    data() {
        return {
            items: []
        };
    },

    methods: {
        add(item) {
            this.items.push(item);

            this.$emit('added');
        },

        remove(index) {
            this.items.splice(index, 1);

            this.$emit('removed');
        }
    }
}

Now the Replies.vue file can be cleaned up. Note we must now import the collection however and update the Vue instance to reflect that.


<template>
    <div>
        <div v-for="(reply, index) in items" :key="reply.id">
            <reply :data="reply" @deleted="remove(index)"></reply>
        </div>

        <new-reply :endpoint="endpoint" @created="add"></new-reply>
    </div>
</template>

<script>
    import Reply from './Reply.vue';
    import NewReply from './NewReply.vue';
    import collection from '../mixins/collection';

    export default {
        components: {Reply, NewReply},

        mixins: [collection],

        data() {
            return {
                dataSet: false,
                endpoint: location.pathname + '/replies'
            }
        },

        created() {
            this.fetch();
        },

        methods: {
            fetch() {
                axios.get(this.url())
                    .then(this.refresh);
            },

            url() {
                return `${location.pathname}/replies`;
            },

            refresh({data}) {
                this.dataSet = data;
                this.items = data.data;
            }
        }
    }
</script>

Everything still works, but the refactor cleans things up a bit which is nice.


Behold! The <paginator> Vue component

Time to actually create the Paginator.vue component that will do the pagination for us. Let’s create the new file, and register the component globally so it can be easily reused anywhere we might like.
new paginator vue file

app.js


require('./bootstrap');

window.Vue = require('vue');

Vue.component('flash', require('./components/Flash.vue'));

Vue.component('paginator', require('./components/Paginator.vue'));

Vue.component('thread-view', require('./pages/Thread.vue'));

const app = new Vue({
    el: '#app'
});

Now we can flesh out the beginning skeleton of the Paginator.vue template.

<template>
    <ul class="pagination">
        <li>
            <a href="#" aria-label="Previous" rel="prev">
                <span aria-hidden="true">« Previous</span>
            </a>
        </li>
        <li>
            <a href="#" aria-label="Next" rel="next">
                <span aria-hidden="true">Next »</span>
            </a>
        </li>
    </ul>
</template>

<script>
    export default {

    }
</script>

We are going to actually render the from within the Replies.vue component. So let’s add this markup like so to Replies.vue as well.

<template>
    <div>
        <div v-for="(reply, index) in items" :key="reply.id">
            <reply :data="reply" @deleted="remove(index)"></reply>
        </div>
        <paginator></paginator>
        <new-reply :endpoint="endpoint" @created="add"></new-reply>
    </div>
</template>

This is enough to start rendering something on the page. It’s not functional yet, but it’s a good start.
paginator rendering


When to show pagination links

The paginator component needs to intelligently figure out if a previous or next link should be displayed based on how many items are in the data collection to be displayed on the page. We can accomplish this by using v-if, and v-show, along with some data properties we can make use of. Those will be shouldPaginate, prevUrl, and nextUrl. Now first off, we recall that the <replies> component is the one that is doing the actual ajax call to the api to get two things. It gets the dataSet, which is kind of like the meta information about the paginator, and the items – which are the replies themselves. Well, the <paginator> component must know about the dataSet. This means we need to bind dataSet in Replies.vue like so.

<paginator :dataSet="dataSet"></paginator>

Therefore, we can now accept that data on the child compoent <paginator> using props: ['dataSet']. In the snippet below the first v-if determines if the paginator should even display, the first v-show determines if the Previous link should display, the second v-show determines if the Next link should display. In the script area we now see that dataSet prop in place to accept the pagination data from the <replies> component. In the data section we have a page, prevUrl, and nextUrl variables set up. Finally we have the computed property that should return true or false and this determines if the paginator displays at all.


<template>
    <ul class="pagination" v-if="shouldPaginate">
        <li v-show="prevUrl">
            <a href="#" aria-label="Previous" rel="prev">
                <span aria-hidden="true">« Previous</span>
            </a>
        </li>
        <li v-show="nextUrl">
            <a href="#" aria-label="Next" rel="next">
                <span aria-hidden="true">Next »</span>
            </a>
        </li>
    </ul>
</template>

<script>
    export default {
        props: ['dataSet'],

        data() {
            return {
                page: 1,
                prevUrl: false,
                nextUrl: false
            }
        },

        computed: {
            shouldPaginate() {
                return !!this.prevUrl || !!this.nextUrl;
            }
        }
    }
</script>

Loading it up in the browser and inspecting this in Vue dev tools shows us that it is in fact getting the data it needs from the parent component. There is a small problem however. It looks like shouldPaginate is now false, when in reality it should actually be true since there is a next_page_url available.
paginator data loading via prop


Introducing the watch object

We can use this feature of VueJS to watch the dataSet property. Now, if that property ever changes, then the data should be updated in the component instantly. If you add this object to the script section of Paginator.vue, this is exactly what we accomplish.

watch: {
    dataSet() {
        this.page = this.dataSet.current_page;
        this.prevUrl = this.dataSet.prev_page_url;
        this.nextUrl = this.dataSet.next_page_url;
    }
},

Vue dev tools is now showing that the dataSet is now sporting a true value for shouldPaginate.
vuejs watch object example


Assigning click handlers to paginated links

Something needs to happen when a user clicks either the Next or Previous links in the paginator. What do we want to happen. Well, if you click the Previous link, then the page variable should reduce by 1. If you click the Next link, then the page variable should increase by 1. This is a simple edit to the Paginator.vue in the template area like so.


<template>
    <ul class="pagination" v-if="shouldPaginate">
        <li v-show="prevUrl">
            <a href="#" aria-label="Previous" rel="prev" @click.prevent="page--">
                <span aria-hidden="true">« Previous</span>
            </a>
        </li>
        <li v-show="nextUrl">
            <a href="#" aria-label="Next" rel="next" @click.prevent="page++">
                <span aria-hidden="true">Next »</span>
            </a>
        </li>
    </ul>
</template>

With this in place, we can click the Next link, then see it update in the Vue dev tools. Pretty cool!
vuejs click then increment


Emitting an event from the child

When a user clicks on one of those links, it indicates that the user wants a new page. That means, an ajax call needs to happen. A good way to tackle this is to add that page property to the watcher, and fire an event if it ever changes. So what we see below is basically saying, if that page variable ever changes, run the broadcast() method which will emit an event called ‘changed’ and pass through the page the user is requesting.


<script>
    export default {
        props: ['dataSet'],

        data() {
            return {
                page: 1,
                prevUrl: false,
                nextUrl: false
            }
        },

        watch: {
            dataSet() {
                this.page = this.dataSet.current_page;
                this.prevUrl = this.dataSet.prev_page_url;
                this.nextUrl = this.dataSet.next_page_url;
            },
            page() {
                this.broadcast();
            }
        },

        computed: {
            shouldPaginate() {
                return !!this.prevUrl || !!this.nextUrl;
            }
        },

        methods: {
            broadcast() {
                return this.$emit('changed', this.page);
            }
        }
    }
</script>

Ok an event was fired, now what? Well, we can listen for that event inside the <replies> component where the <paginator> is referenced like so. This says, when the ‘changed’ event happens, fire the fetch() method to get new data from the server.

<paginator :dataSet="dataSet" @changed="fetch"></paginator>

Pagination Life Cycle

Real quick, let’s hit a recap on the Pagination life cycle.


1. The page loads for the first time and the Paginator displays a Next link.

Paginator displays a Next link


2. The user clicks on the link, and the page variable gets incremented by one in Paginator.vue.

incremented by one in Paginator


3. That page property is being watched, so the broadcast method fires emitting a ‘changed’ event while passing the page number requested.

broadcast method fires


4. Inside the <replies> component, a @changed listener is setup. When it hears that emitted event, it then triggers the fetch() ajax call.

listen for changed event


5. When the ajax call completes, the dataSet and items variables are updated via the refresh() method in Replies.vue.

fetch then refresh


6. The dataSet variable in Replies.vue is bound to the <paginator>. Therefore, the updated dataSet cascades down to the Paginator.vue component.

data binding on parent component


7. The Paginator.vue component accepts that dataSet via a prop.

accept data via prop


8. The Paginator.vue component also has a watch set up on the dataSet, so it updates it’s own page, prevUrl, and nextUrl variables.

watch set up on dataset


9. Therefore, both the replies listed in <replies> and the pagination links in <paginator> update in real time as they are clicked.

pagination update in real time as they are clicked

Paginator.vue

<template>
    <ul class="pagination" v-if="shouldPaginate">
        <li v-show="prevUrl">
            <a href="#" aria-label="Previous" rel="prev" @click.prevent="page--">
                <span aria-hidden="true">« Previous</span>
            </a>
        </li>
        <li v-show="nextUrl">
            <a href="#" aria-label="Next" rel="next" @click.prevent="page++">
                <span aria-hidden="true">Next »</span>
            </a>
        </li>
    </ul>
</template>

<script>
    export default {
        props: ['dataSet'],

        data() {
            return {
                page: 1,
                prevUrl: false,
                nextUrl: false
            }
        },

        watch: {
            dataSet() {
                this.page = this.dataSet.current_page;
                this.prevUrl = this.dataSet.prev_page_url;
                this.nextUrl = this.dataSet.next_page_url;
            },
            page() {
                this.broadcast();
            }
        },

        computed: {
            shouldPaginate() {
                return !!this.prevUrl || !!this.nextUrl;
            }
        },

        methods: {
            broadcast() {
                return this.$emit('changed', this.page);
            }
        }
    }
</script>

Replies.vue

<template>
    <div>
        <div v-for="(reply, index) in items" :key="reply.id">
            <reply :data="reply" @deleted="remove(index)"></reply>
        </div>
        <paginator :dataSet="dataSet" @changed="fetch"></paginator>
        <new-reply :endpoint="endpoint" @created="add"></new-reply>
    </div>
</template>

<script>
    import Reply from './Reply.vue';
    import NewReply from './NewReply.vue';
    import collection from '../mixins/collection';

    export default {
        components: {Reply, NewReply},

        mixins: [collection],

        data() {
            return {
                dataSet: false,
                endpoint: location.pathname + '/replies'
            }
        },

        created() {
            this.fetch();
        },

        methods: {
            fetch(page) {
                axios.get(this.url(page))
                    .then(this.refresh);
            },

            url(page = 1) {
                return `${location.pathname}/replies?page=` + page;
            },

            refresh({data}) {
                this.dataSet = data;
                this.items = data.data;
            }
        }
    }
</script>

Updating URL Addressing and Push State

URL driving the paginator
The paginator itself is now fully functional, however if you visit a URL in the browser such as http://forum.io/threads/quo/53?page=2 for example, it is not driving the paginator correctly. The URL says we should be on page 2 of the paginated results, but page 1 is still showing. We can update the url() method within Replies.vue to use this bit of code.


url(page) {
    if (!page) {
        let query = location.search.match(/page=(\d+)/);
        page = query ? query[1] : 1;
    }
    return `${location.pathname}/replies?page=${page}`;
},

All that happens here is, if there is no value for the page variable, then we can use location.search to inspect the querystring part of the URL in the browser. So using location.search when the URL is http://forum.io/threads/quo/53?page=2 would return “?page=2”. Then we need to apply a regular expression to narrow down the result to just the numeric page value we need. With this addition, the URL is now driving the paginator correctly.

Paginator driving the URL
Now we need to do this in reverse. Right now clicking the links in the paginator correctly moves us forward and backward within the paginated set. The URL in the browser is not updated however. It just stays the same as you click forwards and backwards. This can be fixed with the history.pushState() method in JavaScript. If we add a method to the Paginator.vue component like so, it should enable this feature.

updateUrl() {
    history.pushState(null, null, '?page=' + this.page);
}

The final Paginator code

This tutorial used a dedicated Paginator.vue component in concert with a parent Replies.vue component. It gave us a good opportunity to build the pagination functionality from scratch, and will help us fully understand how pagination in VueJS works with Bootstrap styling and Laravel providing the data via it’s API. The final two files look like so.

Paginator.vue

<template>
    <ul class="pagination" v-if="shouldPaginate">
        <li v-show="prevUrl">
            <a href="#" aria-label="Previous" rel="prev" @click.prevent="page--">
                <span aria-hidden="true">« Previous</span>
            </a>
        </li>
        <li v-show="nextUrl">
            <a href="#" aria-label="Next" rel="next" @click.prevent="page++">
                <span aria-hidden="true">Next »</span>
            </a>
        </li>
    </ul>
</template>

<script>
    export default {
        props: ['dataSet'],

        data() {
            return {
                page: 1,
                prevUrl: false,
                nextUrl: false
            }
        },

        watch: {
            dataSet() {
                this.page = this.dataSet.current_page;
                this.prevUrl = this.dataSet.prev_page_url;
                this.nextUrl = this.dataSet.next_page_url;
            },
            page() {
                this.broadcast().updateUrl();
            }
        },

        computed: {
            shouldPaginate() {
                return !!this.prevUrl || !!this.nextUrl;
            }
        },

        methods: {
            broadcast() {
                return this.$emit('changed', this.page);
            },

            updateUrl() {
                history.pushState(null, null, '?page=' + this.page);
            }
        }
    }
</script>

Replies.vue

<template>
    <div>
        <div v-for="(reply, index) in items" :key="reply.id">
            <reply :data="reply" @deleted="remove(index)"></reply>
        </div>
        <paginator :dataSet="dataSet" @changed="fetch"></paginator>
        <new-reply :endpoint="endpoint" @created="add"></new-reply>
    </div>
</template>

<script>
    import Reply from './Reply.vue';
    import NewReply from './NewReply.vue';
    import collection from '../mixins/collection';

    export default {
        components: {Reply, NewReply},

        mixins: [collection],

        data() {
            return {
                dataSet: false,
                endpoint: location.pathname + '/replies'
            }
        },

        created() {
            this.fetch();
        },

        methods: {
            fetch(page) {
                axios.get(this.url(page)).then(this.refresh);
            },

            url(page) {
                if (!page) {
                    let query = location.search.match(/page=(\d+)/);
                    page = query ? query[1] : 1;
                }
                return `${location.pathname}/replies?page=${page}`;
            },

            refresh({data}) {
                this.dataSet = data;
                this.items = data.data;
            }
        }
    }
</script>

VueJS Bootstrap Pagination Component Summary

final working paginator example
Now we can see that everything is working great. Here we load up a thread with a bunch of replies on the first page. Then we go to the fifth page in the URL bar, and manually load that page. The paginated set correctly loads the replies for that page. Then, we do the reverse. We start clicking the links in the paginator, we see the page changing properly, and now we also see the URL updating correctly thanks to making use of the history.pushState() function.

Click to share! ⬇️