robot mascot smiling
50% Off Adocasts Plus Black Friday Sale Nov 24th - Dec 2nd

How To Create An Infinite Loader

In this lesson, we'll be going over how to add an infinite loader to our app. We'll specifically be utilizing an Edge component and our Model's paginate method to make this happen.

Published
Aug 22, 21
Duration
9m 52s

Developer, dog lover, and burrito eater. Currently teaching AdonisJS, a fully featured NodeJS framework, and running Adocasts where I post new lessons weekly. Professionally, I work with JavaScript, .Net C#, and SQL Server.

Adocasts

Burlington, KY

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.

  1. Anonymous (Deleted)
    Commented 9 months ago
    [deleted]

    Please sign in or sign up for free to reply

    1. Commented 9 months ago

      cnvcgnv

      0

      Please sign in or sign up for free to reply

  2. Commented 9 months ago

    hgmgg

    0

    Please sign in or sign up for free to reply