Subscribe to access all episodes. View plans →

#143: Infinite Scroll with LiveView

Published April 25, 2022

Phoenix 1.6

LiveView 0.17

Scrivener.Ecto 2.7

Follow along with the episode starter on GitHub

In episode 142 we added Scrivener.Ecto to paginate these albums which are being rendered with Phoenix LiveView. Our pagination works great. When we click through to new pages, our albums for that page are displayed. But what if we want to change this approach and instead of doing the classic pagination, we change this to use Infinite Scroll. With Infinite scroll, more albums will be loaded to the page as a user “scrolls” down the page. There are no pagination links, which makes it easy for users to explore your content. Popular apps like Twitter and YouTube both make use Infinite Scroll. And because we’re using Phoenix LiveView, it’s easy to add infinite scroll.

Since we’re using Phoenix LiveView, we’ll create a LiveView JS hook to track when a user scrolls down the page, and when they do, it will send an event to the LiveView responsible for rendering these albums, telling it to load the next batch of albums.

Let’s get started by creating the infinite scroll JS hook. If you’re not familiar with JS hooks I cover them in episode 114. We’ll create a new directory in /js named “pagination” and then we’ll create a file for our hook named infinite_scroll.js. For our JS hook let’s go to the Elixir Forum and borrow some code from Chris McCord. We’ll take this and paste it into our app.

Let’s talk through what’s happening. At the top we have a scrollAt() function, which uses the scrollTop, scrollHeight, and clientHeight to calculate how far down the page the user has scrolled. Then we have our InfiniteScroll hook. The first thing we’re doing is adding a function that will return the page data attribute. We’ll need to add this to the same HTML element we add the InfiniteScroll to. Then we’re adding the mounted callback to our JS Hook. Inside it we’ll set a pending value as and then we’re adding an event listener to the window inside the event listener, we have an if statement for if the pending page is equal to and if our scrollAt() function returns a value greater than 90. This will return true just before a user scrolls to the bottom of the page. When that happens, we’ll update this.pending to + 1, and then we’ll push an event named “load-albums” to our LiveView. We’ll need to add the corresponding handle_event to the LiveView, but this will be responsible for updating the socket with the next batch of albums. Then we have the reconnected and updated callbacks, which will set this.pending page to And at the bottom of the page we’re exporting our InfiniteScroll.

In summary, our InfiniteScroll hook will send the “load-albums” event to our LiveView just before the user has scrolled to the bottom of the page. This will allow us to fetch the next batch of albums from the LiveView and then update the page before the user ever reaches the bottom of the page.

Template path: assets/js/pagination/infinite_scroll.js

let scrollAt = () => {
  let scrollTop = document.documentElement.scrollTop || document.body.scrollTop
  let scrollHeight = document.documentElement.scrollHeight || document.body.scrollHeight
  let clientHeight = document.documentElement.clientHeight

  return scrollTop / (scrollHeight - clientHeight) * 100

InfiniteScroll = {
  page() { return },
    this.pending =
    window.addEventListener("scroll", e => {
      if(this.pending == && scrollAt() > 90){
        this.pending = + 1
        this.pushEvent("load-albums", {})
  reconnected(){ this.pending = },
  updated(){ this.pending = }

export default InfiniteScroll

Alright with our hook created let’s open our app.js and we’ll import our InfiniteScroll hook. Then we’ll add it to the LiveSocket.

Template path: assets/js/app.js

import InfiniteScroll from "./pagination/infinite_scroll"

let Hooks = { InfiniteScroll }
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}, hooks: Hooks})

Now let’s make some changes to the template that renders our albums we’ll open the “album_live/index.html.heex” template. Then we’ll remove the existing pagination since we won’t need it with infinite scroll. Let’s go to the <tbody> tag and since this is where our albums are rendered, we’ll want to mark this up to work with our InfiniteScroll hook.

Hooks require a unique DOM ID so let’s add that. Then we’ll set phx-hook="InfiniteScroll" and because we want to append our albums to the table we’ll add phx-update="append". We’ll set the data-page attribute that our InfiniteScroll hook expects with the current page. This will need to be added to the LiveView. Because we’re no longer using the classic pagination - we’re just rendering albums - we’ll want to change @page.entries to just @albums.

Template path: lib/teacher_web/live/album_live/index.html.heex

<tbody id="infinite-scroll" phx-hook="InfiniteScroll" phx-update="append" data-page={@page}>
  <%= for album <- @albums do %>
      <td><%= album.artist %></td>
      <td><%= album.summary %></td>
      <td><%= album.title %></td>
      <td><%= album.year %></td>

        <span><%= link "Show", to: Routes.album_path(@socket, :show, album) %></span>
  <% end %>

Now that our JS and HTML have been updated the next step will be to update our LiveView to handle these changes. We’ll open the AlbumLive.Index LiveView that is responsible for rendering the albums and our InfiniteScroll hook is going to trigger an event called “load-albums” just before the user scrolls to the bottom of the page, so let’s handle that event by adding a handle_event callback pattern matching on the “load-albums” event. We can ignore the params since we won’t need them and then the socket.

When our callback is invoked we want to do two things. First, fetch the next batch of albums and then update the socket with those albums. To fetch the batch of albums we’ll need to get our current page from the socket assigns and then add 1 to it so we’re fetching the next page of albums to load.

With the new page, we can call Recordings.paginate_albums passing in the map of page data that Scrivener expects. Then we’ll return a noreply tuple and to update our socket we’ll need to update the page assign and the albums assign. Previously we were using the handle_params callback above to update our page with albums on the initial page load and then when a user clicked the pagination links.

Since we no longer have pagination links, let’s remove this callback and update the LiveView to load the initial albums and page in the mount callback. Also, we’re now using page as the current page number in our template so let’s update it to use that and then put our initial batch of albums in the albums assign.



@impl true
def mount(params, _session, socket) do
  page = Recordings.paginate_albums(params)

    |> assign(page: 1)
    |> assign(albums: page.entries)}

@impl true
def handle_event("load-albums", _params, socket) do
  assigns = socket.assigns
  next_page = + 1
  page = Recordings.paginate_albums(%{"page" => next_page})

    |> assign(page: next_page)
    |> assign(albums: page.entries)}


With our changes, let’s go to the browser. And no albums are being displayed.

If we go back to our index.html.heex template this is happening because we’re using phx-update=append to append those new albums to the table here. When using phx-update we need to add an ID to the container - which we’re doing - as well as to each child, which we’re missing. So let’s go ahead and add that.

Template path: lib/teacher_web/live/album_live/index.html.heex

<%= for album <- @albums do %>
  <tr id={"album-#{}"}>
    <td><%= album.artist %></td>
<% end %>

Now when we go back to the browser our albums are displayed. So let’s try scrolling down the page and when we do our new albums are fetched and appended to the bottom of the table until we run out of albums to display. Our application is now set up to use infinite scroll with Phoenix LiveView.