What we’re building
In the last lesson we built a feature to filter blog posts by category, but it’s limited to the first 10 results. In this lesson, we’ll add in a skeleton loader (a temporary loading UI) and a “Load More” button to fetch more posts.
Simulate a delay from the server
If you’re working on your local machine, you’re probably not noticing much a delay when loading in new posts, but on a production server, there could be a slight delay for a number of reasons – your reader’s internet speed being one of them.
Lets simulate a 1.5 second delay from the server with a JavaScript setTimeout()
.
Inside of your JavaScript, we’ll wrap the fetch()
method with a setTimeout()
:
fetchPosts() {
...
// Simulate delay from server
setTimeout(() => ({
fetch(adminURL, {
method: "POST",
body: formData,
})
.then((res) => res.json())
.then((res) => {
this.posts = res.posts;
});
}, 1500));
}
With that 1.5 second timeout, we should start to see what our post filter feature would look like in a production environment. There’s a noticeable delay where there’s not content at all until new posts appear:
The loading state
In order to display a skeleton loader, we need to know whether or not we’re in a loading state.
- When loading more posts, we’ll set
loadingMore
totrue
. As soon as we get our posts, we’ll setloadingMore
tofalse
. - When we change to a new category, we’ll set
loadingNew
totrue
and later set it tofalse
when we get our posts.
Let’s add a loadingNew
and loadingMore
property to our Alpine component. They should both default to false
.
Alpine.data("filterPosts", (adminURL) => ({
posts: null,
category: null,
showDefault: true,
showFiltered: false,
loadingNew: false, // <--- defaults to false
loadingMore: false, // <--- defaults to false
...
})
Why two loading properties?
We’re controlling the visibility of three sections:
- The default posts
- The dynamic posts
- The skeleton loader
Having two loading properties helps us control the display of the default and filtered posts in four scenarios:
- Initial load
Show the default posts (both loading states are false). - Load new posts
Still show the default posts while showing the skeleton loader withloadingMore
- Filter category
Hide the default posts, show the skeleton loader withloadingNew
, and then show the filtered posts. - Filter category and then load new posts
Hide the default posts, show the skeleton loader (bothloadingNew
andloadingMore
equal true), and then show the filtered posts.
Set the loading state
When changing categories, update loadingNew
to true.
filterPosts(id) {
this.loadingNew = true;
...
},
Create a new method called loadMore()
and add it right after filterPosts()
. When loading more posts, we’ll update loadingMore
to true.
loadMore() {
this.loadingMore = true;
},
Once we’ve received our posts, reset both loading states to false
:
fetchPosts(append = false) {
...
fetch(adminURL, {
method: "POST",
body: formData,
})
.then((res) => res.json())
.then((res) => {
if (append) {
this.posts = this.posts.concat(res.posts);
} else {
this.posts = res.posts;
}
// Reset both loading states
this.loadingNew = false;
this.loadingMore = false;
});
},
Skeleton Template
Let’s update template-parts/posts-filter/template.php
to include our skeleton loader template:
<!-- Posts Column -->
<div class="w-3/4">
<!-- Default posts query -->
<div x-show.important="showDefault" class="grid grid-cols-2 gap-6">
<?php get_template_part( 'template-parts/posts-filter/default-query' ); ?>
</div>
<!-- Filtered posts -->
<div x-show.important="showFiltered" class="grid grid-cols-2 gap-6" x-html="posts"></div>
<!-- Skeleton Loader -->
<template x-if="loadingNew || loadingMore">
<div class="grid grid-cols-2 gap-6">
<template x-for="i in 4">
<div>
<div class="h-52 w-full bg-slate-200 rounded-lg"></div>
<div class="h-8 w-1/2 bg-slate-200 rounded-lg mt-4"></div>
</div>
</template>
</div>
</template>
</div>
There are a few things going on here worth mentioning:
-
We’ve added in a
<template>
tag to wrap our skelton UI:The skeleton UI is displayed if either
loadNew
orloadMore
are true. -
We’re using
x-if
The difference between
x-show
andx-if
is x-show hides elements withdisplay: none;
while x-if completely removes the markup from the DOM. -
There’s a second
<template>
to loop 4 iterations of a skeleton UI. This is the same as hardcoding 4 divs that look like a skeleton post. Looping it just makes it cleaner.
You should now have a functioning skeleton UI for the loading state:
Load More Button
A “Load More” button will allow readers to view more than the initial 10 posts. When the button is clicked, we’ll append 10 new posts to the end of the list.
Let’s add a button to the bottom of our posts column:
<!-- Posts Column -->
<div class="w-3/4">
...
<!-- Load More Posts -->
<div class="text-center mt-12">
<button
@click="loadMore()"
class="bg-white border border-solid border-slate-200 hover:bg-slate-100 px-4 py-2">
Load More
</button>
</div>
</div>
Load more method in Alpine.JS
Our loadMore()
method is responsible for loading 10 additional posts.
We don’t want to load the same 10 posts again, but instead load the next 10 posts. To do this we’ll pass an offset
to WP_Query
that increases by 10 every time we load new posts.
Set a default offset
Add a new offset
property to your Alpine component. It should default to zero:
Alpine.data("filterPosts", (adminURL) => ({
posts: null,
category: null,
showDefault: true,
showFiltered: false,
loading: false,
offset: 0,
When the Load More button is clicked, our loadMore()
method will:
- Set
loadingMore()
to true - Increase the
offset
by 10. - Set
showFiltered
to true - our dynamic posts only display if the reader requests posts by changing a category or loading more posts. - Call
fetchPosts(true)
with a new parameter for appending posts.
...
loadMore() {
this.loadingMore = true;
this.offset += 10;
this.showFiltered = true;
this.fetchPosts(true);
},
...
Update fetchPosts()
to support appending new posts
- Add the new
append
parameter and default it to false. - Add
offset
to the form data. - Within
fetch()
, check ifappend
is true and if so, append the new posts withconcat()
.
...
// append defaults to false
fetchPosts(append = false) {
var formData = new FormData();
formData.append("action", "ajaxPosts");
formData.append("offset", this.offset); // <-- Add new data to the form
...
fetch(adminURL, {
method: "POST",
body: formData,
})
.then((res) => res.json())
.then((res) => {
if (append) {
// Appends posts to the end of existing posts data
this.posts = this.posts.concat(res.posts);
} else {
// Resets the posts data with new data
this.posts = res.posts;
}
this.loading = false;
});
},
Reset offset if changing categories
We need to reset the offset
to zero if we change categories. Otherwise we’ll load in the wrong set of posts for the new category selected.
filterPosts(id) {
this.loadingNew = true;
this.showFiltered = true;
this.category = id;
this.offset = 0; // <-- reset offset to zero
this.fetchPosts();
},
Hiding the Load More button
If there are no more posts to load, we should hide the Load More button.
To do that, Alpine needs know how many posts are available total - regardless of the limit and offset that we supply in WP_Query.
That means we need to include a total
in the response from our filterPosts()
PHP function in ./inc/query-posts.php
.
function filterPosts()
{
$response = [
'posts' => "",
];
// New args for an All Posts Query
$all_args = array(
'posts_per_page' => -1, // returns all posts
'post_status' => 'publish',
'post_type' => 'post'
);
$filter_args = array(
'post_status' => 'publish',
'post_type' => 'post'
);
if ($_POST['limit']) {
$filter_args['posts_per_page'] = $_POST['limit'];
}
if ($_POST['category']) {
$filter_args['cat'] = $_POST['category'];
// Get the total number of posts for the category
$all_args['cat'] = $_POST['category'];
}
$filter_query = new WP_Query($filter_args);
// New All Posts Query
$all_query = new WP_Query($all_args);
if ($filter_query->have_posts()) :
while ($filter_query->have_posts()) : $filter_query->the_post();
$response['posts'] .= load_template_part('/template-parts/posts-filter/single-post');
endwhile;
wp_reset_postdata();
endif;
echo json_encode($response);
die();
}
Now when Alpine makes a request to this function, we’ll get back a total
of all published posts in addition to the blog posts from that query.
We can update our Load More button to contain an x-show
to only display of the total
is greater than the current number of posts displayed.
Add x-show="total > (limit + offset)"
to the <button>
<!-- Load More Posts -->
<div
@click="loadMore()"
x-show="total > (limit + offset)"
class="text-center mt-12">
<button
class="border border-solid border-slate-700 text-slate-100 hover:bg-slate-800 px-4 py-2">
Load More
</button>
</div>
Lastly, we need fetch the total
even when the initial default posts display. Currently, when the page initially loads, Alpine is not calling our filterPosts()
PHP function.
Alpine has an init()
method that allows us to run some code when the component initializes. We’ll create a new getTotal()
method that runs on init()
. You can add this new getTotal()
and the init()
method right after fetchPosts()
:
getTotal() {
var formData = new FormData();
formData.append("action", "filterPosts");
fetch(adminURL, {
method: "POST",
body: formData,
})
.then((res) => res.json())
.then((res) => {
this.total = res.total;
});
},
init() {
this.getTotal();
}
Now when the page loads and the Alpine component initializes, we’ll have a total
to start with. That total
is updated if we change categories too.