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
<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
<?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.
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);
},
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?
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.
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.
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
<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.
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.
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.
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!
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.
2. The user clicks on the link, and the page
variable gets incremented by one in Paginator.vue.
3. That page
property is being watched, so the broadcast method fires emitting a ‘changed’ event while passing the page number requested.
4. Inside the <replies> component, a @changed listener is setup. When it hears that emitted event, it then triggers the fetch() ajax call.
5. When the ajax call completes, the dataSet
and items
variables are updated via the refresh() method in Replies.vue.
6. The dataSet
variable in Replies.vue is bound to the <paginator>. Therefore, the updated dataSet
cascades down to the Paginator.vue component.
7. The Paginator.vue component accepts that dataSet
via a 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.
9. Therefore, both the replies listed in <replies> and the pagination links in <paginator> 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
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.