Infinite loading in Inertia JS
Published 20/12/2021 | 9495 viewsInfinite loading is a common paradigm for index pages. With Inertia JS, it's simple to integrate. Let me show you how!
Currently, I'm building the news.pestphp.com site in public on my streaming series: Pest in Practice. I decided to go with Inertia JS for this build, because I wanted to play with it more and really love the developer experience it offers.
Take a look at the header image for a moment. On this blog index page, we want to show a list of blog posts, ordered by the publish date descending. Of course, as the site grows, the number of posts will rapidly increase. We certainly don't want to load in every post at once.
Thankfully, Laravel offers amazing pagination support to handle this, and we make use of that feature in the codebase. Here's the gist of that controller:
$posts = Post::query()->published()->paginate(12); return Inertia::render('Posts/Index', ['posts' => $posts]);
The actual implementation is a little more in-depth than that, but this gives you an idea. We're getting posts from the database and paginating by groups of 12.
On a side note, 12 is a great number for pagination in grid layouts because it's divisible by 2, 3, 4, and 6, which means that you can easily have each 'page' have a complete row of items on all screen sizes.
Anyway, let's get back on track. Inertia supports Laravel's pagination pretty seamlessly; it will automatically update the URL for us and getting the next page is just a matter of creating InertiaLink
s for each page URL returned in the result set. Whilst still dynamic, this will replace the original set of posts on the page, which we don't want. Instead, we want to grab the next page of results and concatenate them with already loaded posts.
You may immediately think that we need to reach for a separate API endpoint and use something like axios here, but in reality we don't! We can instead use a few clever Inertia tricks to do all of the heavy lifting for us.
The solution
Let's start with the basics. Imagine our Vue page component for the blog index looks like this:
<template> <ul> <li v-for="post in posts.data" :key="post.id"> <InertiaLink :href="post.href"> {{ post.title }} </InertiaLink> </li> </ul></template> <script>export default { name: "Blog", props: ['posts']}</script>
The first thing we need to do here is move the posts
prop into a data property. This will allow us to append to it dynamically later, even though Inertia will switch out the value of the property when we request the next page of data.
<template> <ul> <li v-for="post in allPosts" :key="post.id"> <InertiaLink :href="post.href"> {{ post.title }} </InertiaLink> </li> </ul></template> <script>export default { name: "Blog", props: ['posts'], data() { return { allPosts: this.posts.data } } }</script>
The posts
prop will now contain the paginated data for the current page of posts, whereas on mount, the allPosts
data property will contain just the posts found in that property. Note that we've updated the HTML too to reflect our changes.
Asking Inertia to fetch more posts
We now need a method that will allow us to load the next page of posts based on the next_page_url
property returned from Laravel's pagination object.
<template> <ul> <li v-for="post in allPosts" :key="post.id"> <InertiaLink :href="post.href"> {{ post.title }} </InertiaLink> </li> </ul></template> <script>export default { name: "Blog", props: ['posts'], data() { return { allPosts: this.posts.data } }, methods: { loadMorePosts() { if (this.posts.next_page_url === null) { return } this.$inertia.get(this.posts.next_page_url, {}, { preserveState: true, preserveScroll: true, only: ['posts'], onSuccess: () => { this.allPosts = [...this.allPosts, ...this.posts.data] } }) } } }</script>
So, let's break down what's happening here. When the loadMorePosts
method is called, we're first going to check if the posts
prop actually has a next_page_url
. In other words, "are there actually any more posts to load?" If there aren't, we'll stop there.
However, if there are, we're going to use the $inertia
class to load that url. Now, here's the thing. That URL will point to the same Inertia page component. It will just have a different ?page
query parameter. As such, Inertia is going to treat this as a data reload rather than completely switching the component out, which is exactly what we need.
In the options, we set preserveState
and preserveScoll
to true. This will ensure that we aren't thrown back to the top of the page and that our allPosts
data is retained, which is very important for this to work.
Optionally, but highly recommended, you can pass the only
property in the options object. This ensures that Inertia won't load the entire payload again, only the posts
property when it goes to retrieve the next page. For this to be useful, make sure you've used lazy loading in your controller method.
Finally, we need to do something with the returned data. In our onSuccess
handler, we append any new posts found in the now updated posts
property to our existing allPosts
data property.
Hope that all makes sense.
Loading more posts automatically
Now obviously, we need a way to call this method when we reach the bottom of our list of posts. To achieve this, we can make use of an IntersectionObserver
, which is a very handy JavaScript feature that allows us to execute code when something enters or leaves the viewport.
<template> <ul> <li v-for="post in allPosts" :key="post.id"> <InertiaLink :href="post.href"> {{ post.title }} </InertiaLink> </li> </ul> <span ref="loadMoreIntersect"/> </template> <script>export default { name: "Blog", props: ['posts'], mounted() { const observer = new IntersectionObserver(entries => entries.forEach(entry => entry.isIntersecting && this.loadMorePosts(), { rootMargin: "-150px 0px 0px 0px" })); observer.observe(this.$refs.loadMoreIntersect) } data() { return { allPosts: this.posts.data } }, methods: { loadMorePosts() { if (this.posts.next_page_url === null) { return } this.$inertia.get(this.posts.next_page_url, {}, { preserveState: true, preserveScroll: true, only: ['posts'], onSuccess: () => { this.allPosts = [...this.allPosts, ...this.posts.data] } }) } }}</script>
First of all, we add an HTML element in our template right after our list of posts. Then, in our mounted
hook, we create a new IntersectionObserver
that calls our loadMorePosts
method when the HTML element is 150px below the viewport. Adding that 150px margin allows for the new posts to have already loaded by the time the user scrolls to that part of the page, so they don't have to wait.
Then, we instruct our IntersectionObserver
to begin observing our HTML span element that we added. You could do this with a querySelector
, but I prefer to use Vue's refs
.
We now have a working implementation of infinite loading in Inertia JS!
Solving the URL problem
There is a little catch with our current implementation. As you scroll and load more posts, take a look at the browser URL. It changes each time we load a new page. This means that if you reload the page, we actaully skip a bunch of posts.
I'm hoping that soon, Inertia adds a way of preventing the URL from updating when you don't want it to, but in the meantime, there is a workaround.
<template> <ul> <li v-for="post in allPosts" :key="post.id"> <InertiaLink :href="post.href"> {{ post.title }} </InertiaLink> </li> </ul> <span ref="loadMoreIntersect"/></template> <script>export default { name: "Blog", props: ['posts'], mounted() { const observer = new IntersectionObserver(entries => entries.forEach(entry => entry.isIntersecting && this.loadMorePosts(), { rootMargin: "-150px 0px 0px 0px" })); observer.observe(this.$refs.loadMoreIntersect) } data() { return { allPosts: this.posts.data, initialUrl: this.$page.url, } }, methods: { loadMorePosts() { if (this.posts.next_page_url === null) { return } this.$inertia.get(this.posts.next_page_url, {}, { preserveState: true, preserveScroll: true, only: ['posts'], onSuccess: () => { this.allPosts = [...this.allPosts, ...this.posts.data] window.history.replaceState({}, this.$page.title, this.initialUrl) } }) } }}</script>
What's going on here? Well, when the page is first loaded, we track the initial url (/blog
for example). Then, once we've finished loading in the next page, we use the history
API to replace the current browser URL with that initial URL we saved on mount. This means that the browser URL will always read /blog
, no matter which page we're on, and refreshing the browser will simply load the first page of blog posts again.
Conclusion
So, with that, we have a fully working infinite loader in Inertia JS, without any additional endpoints or complexity. You can see that this is pretty simple. I continue to be impressed with Inertia's flexibility; it really is a fun and powerful tool to use.
Thanks for sticking along for the ride. I hope you learned something new!
Kind Regards, Luke