Subscribe to access all episodes. View plans →
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 this.page
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 this.page
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 this.page
+ 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 this.page()
. 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.el.dataset.page },
mounted(){
this.pending = this.page()
window.addEventListener("scroll", e => {
if(this.pending == this.page() && scrollAt() > 90){
this.pending = this.page() + 1
this.pushEvent("load-albums", {})
}
})
},
reconnected(){ this.pending = this.page() },
updated(){ this.pending = this.page() }
}
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 %>
<tr>
<td><%= album.artist %></td>
<td><%= album.summary %></td>
<td><%= album.title %></td>
<td><%= album.year %></td>
<td>
<span><%= link "Show", to: Routes.album_path(@socket, :show, album) %></span>
</td>
</tr>
<% end %>
</tbody>
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.
lib/teacher_web/live/album_live/index.ex
...
@impl true
def mount(params, _session, socket) do
page = Recordings.paginate_albums(params)
{:ok,
socket
|> assign(page: 1)
|> assign(albums: page.entries)}
end
@impl true
def handle_event("load-albums", _params, socket) do
assigns = socket.assigns
next_page = assigns.page + 1
page = Recordings.paginate_albums(%{"page" => next_page})
{:noreply,
socket
|> assign(page: next_page)
|> assign(albums: page.entries)}
end
...
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-#{album.id}"}>
<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.