Now that we have our application set up and rendering out our initial page worth of posts, it's time to learn how we can add an infinite load onto our page so that posts continuously load as the user scrolls down the page.
Extract Our Post List Component
First, let's extract our each
loop out of our page and into a component. We'll be creating an API endpoint that'll need to be able to render out the exact same HTML as our page's initial list. So, by extracting this list out into an Edge component we're giving our API the ability to access and render out that HTML with whatever data it needs.
So, let's create a new folder called components
and inside that folder let's create a new file called post_list.edge
. Then, cut our each
loop out of our posts/index.edge
page and paste it into our new components/post_list.edge
component.
Additionally, components only have access to data you specifically pass it, so instead of passing it our entire paginated data, let's just pass it the minimum it needs to know, which is our posts records.
{{-- resources/views/components/post_list.edge --}} @each(post in posts) <div style="padding:2rem;"> <h3>{{ post.title }}</h3> <p>{{ post.summary }}</h3> </div> @endeach
Copied!
Next, in the same spot that we cut our each
loop out of our posts/index.edge
page, let's add a call to our new component.
{{-- resources/views/posts/index.edge --}} <h1 style="padding: 2rem;">Posts</h1> <div id="scroller" style="position: relative;"> @!component('components/post_list', { posts: page.rows }) </div>
Copied!
Note that, since our component doesn't utilize slots we can make it self-closing by adding an !
directly after the starting @
symbol.
Add A Scroll Buffer
To actually listen for when to load more posts onto the page, we're going to make use of an IntersectionObserver
. With an IntersectionObserver
we can have it observe a particular element on the page and have it notify us when that element enters the user's screen.
Ideally, we wouldn't want to wait for the user to reach the very bottom of our list, we'd like to have some buffer between more posts loading and the user actually reaching the bottom of our list. So, let's create that buffer.
{{-- resources/views/posts/index.edge --}} <h1 style="padding: 2rem;">Posts</h1> <div id="scroller" style="position: relative;"> <div class="scroller_list"> @!component('components/post_list', { posts: page.rows }) </div> <div class="scroller_buffer" style="position: absolute; bottom: 0; left:0; width: 100%; height: 50vh; pointer-events: none;"></div> </div>
Copied!
We'll wrap our list and our buffer in a relatively positioned element. Then, we'll absolutely position our buffer to the bottom of our list. We'll make the height of the list half of the user's screen height. This means that once the user is half a page away from reaching the bottom of our list, we'll be notified to load in more posts for the user. We'll also make this buffer pointer-events: none
so that the user can't interact with it.
Adding Our Script
Now that our page's elements are set up properly for infinite load, we're ready to write our script. This is where we'll register our IntersectionObserver
and call our API, which we'll create in a bit, to fetch more posts.
{{-- resources/views/posts/index.edge --}} {{-- ... HTML ... --}} <script> const scrollerList = document.querySelector('#scroller .scroller_list') const scrollerBuffer = document.querySelector('#scroller .scroller_buffer') const states = { IDLE: 0, WORKING: 1, DONE: 2 } let state = states.IDLE let currentPage = {{ page.currentPage }} </script>
Copied!
First, let's create our script and grab our scrollerList
and scrollerBuffer
elements. We'll also define some states so we can keep track of the state our script is in, which we'll default to idle. Next, we'll define a variable to track the user's current page, we can set this directly from our page's currentPage
property
{{-- resources/views/posts/index.edge --}} {{-- ... HTML ... --}} <script> const scrollerList = document.querySelector('#scroller .scroller_list') const scrollerBuffer = document.querySelector('#scroller .scroller_buffer') const states = { IDLE: 0, WORKING: 1, DONE: 2 } let state = states.IDLE let currentPage = {{ page.currentPage }} const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { fetchNextPage() } }) }) observer.observe(scrollerBuffer) </script>
Copied!
Next, we'll create our IntersectionObserver
, passing it a callback function that loops over the observed entries, listening for any entries that are marked as isIntersecting
. If an entry is intersecting, we'll call fetchNextPage
, which we'll create next. Then, we observe our scrollerBuffer
with our new IntersectionObserver
.
{{-- resources/views/posts/index.edge --}} {{-- ... HTML ... --}} <script> const scrollerList = document.querySelector('#scroller .scroller_list') const scrollerBuffer = document.querySelector('#scroller .scroller_buffer') const states = { IDLE: 0, WORKING: 1, DONE: 2 } let state = states.IDLE let currentPage = {{ page.currentPage }} const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { fetchNextPage() } }) }) observer.observe(scrollerBuffer) async function fetchNextPage() { if (state === states.WORKING) return state = states.WORKING const nextPage = ++currentPage; const { html, page } = await fetch(`/api/posts/paginate/${nextPage}`).then(r => r.json()) scrollerList.innerHTML += HTML state = states.IDLE if (nextPage >= page.meta.last_page) { observer.unobserve(scrollerBuffer) state = states.DONE } } </script>
Copied!
In our fetchNextPage
method, we're first checking to see if we're already working on getting the next page. If we are, we'll just kick back out of the method. Then, we'll increment our currentPage
and set that incremented value in a more readable variable called nextPage
.
Then, we actually make our request. We'll define our API endpoint as /api/posts/paginate/:page
. We'll set up this endpoint in a minute, but what we'll want to provide ourselves back is a payload containing our rendered post_list.edge
component as an HTML string, and our next page's data. Then, we’ll concatenate the returned HTML onto our scrollerList
HTML to render it onto the page.
Lastly, we'll check to see if our current page is greater than or equal to our post's maximum page, which we can grab off our page's meta object. If our current page is greater than or equal to our post's maximum page, we'll want to unobserve our scrollerBuffer
from our IntersectionObserver
. This will prevent further pages from being requested since we already have all the data we could possibly fetch.
Creating Our API Endpoint
Let's head back into our PostsController
and create a new method called paginate
. In this, we'll need our response, params, and view from our HttpContext
.
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' import Post from 'App/Models/Post'; export default class PostsController { private perPage = 10 /** * Displays home page for posts * This is our initial list of 10 posts * @param param0 * @returns */ public async index ({ view }: HttpContextContract) { const page = await Post.query().paginate(1, this.perPage) return view.render('posts/index', { page }) } /** * Renders and returns html and page info for a specific page worth of posts * This is what we use to incrementally continue our initial list * @param param0 * @returns */ public async paginate({ response, params, view }: HttpContextContract) { const page = await Post.query().paginate(params.page, this.perPage) const html = await view.render('components/post_list', { posts: page }) return response.json({ html, page }) } }
Copied!
Within our paginate
method, we're grabbing the requested page of data by passing the page param into our paginate
method as the current page argument. Then, we're utilizing the view
module to render out our components/post_list.edge
component, giving us back the finalized HTML. Then, we return this back as JSON.
Next, let's define the route for this API endpoint.
import Route from '@ioc:Adonis/Core/Route' Route.get('/', async ({ view }) => { return view.render('welcome') }) Route.get('/posts', 'PostsController.index').as('posts.index') Route.get('/api/posts/paginate/:page', 'PostsController.paginate').as('posts.paginate')
Copied!
We'll want it to accept a route param called, page. Then use the PostsController
paginate
method to handle the request. Lastly, we'll give our route a name of posts.paginate
using the as
method.
Test It Out
The last thing to do is to test it out! Start your server if it's not running and head over to your posts page at http://localhost:3333/posts. Open up your dev tools and watch the networks tab, specifically for XHR requests. Then continuously scroll through your posts and watch the page's get requested. Note that once you reach the end of your list the requests stop occurring since we've unobserved from our IntersectionObserver
.
Join The Discussion! (2 Comments)
Please sign in or sign up for free to join in on the dicussion.
Anonymous (Deleted)
Please sign in or sign up for free to reply
rick-killmonger
cnvcgnv
Please sign in or sign up for free to reply
rick-killmonger
hgmgg
Please sign in or sign up for free to reply