This is a post about how to build an infinite scrolling LiveView page that shows a list of photos. It can be used for lots more than photos, but pretty pictures are more fun to look at than a list of products. If you want to deploy your Phoenix LiveView app right now, then check out how to get started. You could be up and running in minutes.
We see plenty of examples around the web of infinite scrolling content. Phoenix LiveView gives us some nifty abilities to do this elegantly and smoothly without needing any frontend frameworks.
In this post we’re going to build a very simple infinite scroll page with LiveView, using TailwindCSS for the grid layout. Check out the Demo app and the final code named complete branch
. The repo’s main
branch is the starting point for this post if you want to follow along.
Special thanks to fly.io for the demo app as well as Pexels for our test photos.
Problem
You have a project that needs to show a grid layout of images. It should allow continuous scrolling and fetch more data as the user scrolls down.
It should look something like this:
Browsing around the web, you’ve seen lots of sites that use infinite scrolling. You know it can be done. Someone suggested using a JavaScript frontend library to build this feature. While that would work, you’d have to build out a paginating REST API, define how to serialize the data, and more to just to support the component.
You’re already using Phoenix and you think, “I’ll bet LiveView could do this well. I might even get it done faster while still delivering a smooth experience.”
Okay, you’re up for it. But now the question is…
How do we create an infinite scrolling page of images in a grid layout using LiveView?
Solution
There are at least 2 important parts in our solution:
- We’ll use the browser’s Intersection Observer API for observing scroll events and
phx-hook
to link it up. We put an element that acts as a target at the bottom of the page, then it’ll trigger events. - DOM patches are done using
phx-update
="append"
. This adds images to the end instead of replacing items.
Following Along
If you’d like to follow along, clone the starter files and follow the instructions in the README and join me back here.
Route for our page
Using the starter project, we’ll replace the default route that’s being used by PageController
:
# router.ex
...
scope "/", InfiniteScrollWeb do
pipe_through :browser
live "/", HomeLive.Index, :index
# get "/", PageController, :index
end
...
Create files for our LiveView and LiveComponent
Here’s what we’ll add inside infinite_scroll_web/
folder:
infinite_scroll_web/
├── ...
├── live/
│ ├── components/
│ │ └── gallery_component.ex
│ └── home_live/
│ ├── index.ex
│ └── index.html.heex
└── ...
That’s 3 folders and 3 files. Based on what we see here:
live/
is where we save our LiveView/LiveComponent files. We instructed our router to point us to a LiveView page instead of a regular template.components/
is where we save stackable elements, e.g. reusable modal.home_live/
is where where we save our parent layout for the homepage (/
), logic for loading the images as well as our state.
Code to add
Parent layout where our component lives:
<!-- infinite_scroll_web/live/home_live/index.html.heex -->
<section class="my-4">
<.live_component
module={GalleryComponent}
id="infinite-gallery-home"
images={@images}
page={@page}
/>
</section>
Our data comes through the images={@images}
attribute, while the @page
assigns serves as our page number after every page load. That’s 2 states that our component needs.
Gallery Component
This is a Live component. That means it’s a reusable function that contains our HEEx (html) template.
# infinite_scroll_web/live/components/gallery_component.ex
defmodule InfiniteScrollWeb.Components.GalleryComponent do
use InfiniteScrollWeb, :live_component
defp random_id, do: Enum.random(1..1_000_000)
def render(assigns) do
~H"""
<div>
<div
id="infinite-scroll-body"
phx-update="append"
class="grid grid-cols-3 gap-2"
>
<%= for image <- @images do %>
<img id={"image-#{random_id()}"} src={image} />
<% end %>
</div>
<div id="infinite-scroll-marker" phx-hook="InfiniteScroll" data-page={@page}></div>
</div>
"""
end
end
I want to draw special attention to the following parts:
phx-update="append"
used for adding new imagesphx-hook="InfiniteScroll"
for detecting when we are at the bottom of the page and loading another set of images.- Notice that the div
phx-hook="InfiniteScroll"
is at the bottom, it acts as a target when we scroll at the bottom. phx-update
andphx-hook
attributes needs anid
attribute as well as each child elements that’s using state (e.g.@title, @image, etc.
).
Initial state and event handle
The code below is where we have our initial state (since we don’t use Ecto) and a function (load-more
) getting the next set of images. We’ll explain what they do below:
# infinite_scroll_web/live/home_live/index.ex
defmodule InfiniteScrollWeb.HomeLive.Index do
use InfiniteScrollWeb, :live_view
alias InfiniteScrollWeb.Components.GalleryComponent
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(page: 1),
temporary_assigns: [images: []]
}
end
@impl true
def handle_event("load-more", _, %{assigns: assigns} = socket) do
{:noreply, assign(socket, page: assigns.page + 1) |> get_images()}
end
defp get_images(%{assigns: %{page: page}} = socket) do
socket
|> assign(page: page)
|> assign(images: images())
end
defp images do
url = "https://images.pexels.com/photos/"
query = "?auto=compress&cs=tinysrgbg&w=600"
~W(
2880507 13046522 13076228 13350109 13302244 12883181
12977343 13180599 12059441 6431576 10651558 5507243
13386712 13290875 13392891 13156418 8581056 13330222
10060916 8064098
)
|> Enum.map(&("#{url}#{&1}/pexels-photo-#{&1}.webp#{query}"))
|> Enum.shuffle()
end
end
There are a few important points we should pay attention to here:
- The
mount/3
function is called for initial page load and to establish the live socket. Thetemporary_assigns
: [images: []]
tells us that the value will reset after every render. handle_event/3
function is called from the JS file (hooks, client-side) that we will create later. The purpose of this function is to load our images.- Our list of images (data) lives in
images/0
function.
Both mount/3
and handle_event/3
are callback functions that’s needed for our LiveView (and hook) to work.
If you wondered by our temporary_assigns
didn’t call get_images/1
, there’s a reason for that:
- Assigns are stateful by default. Having to resend full list on every update is expensive!
- Images that are saved in assigns are stored in memory (in our server) and holds it in the entire session. As your state grows, the performance of your app might be of concern here.
- Also note that using temporary assigns reverts to the default value every update.
- Based in the docs,
mount/3
is invoked twice: once to do the initial page load and again to establish the live socket. If we addget_images/1
inside mount, you’ll notice it’ll render twice (for lack of a better term:double mounting
). One way to mitigate this is to useconnected?/1
to check initial socket load, something like this:
def mount(_params, _session, socket) do
socket = assign(socket, page: 1)
# on initial load it'll return false,
# then true on the next.
if connected?(socket) do
get_images(socket)
else
socket
end
{:ok, socket, temporary_assigns: [images: []]}
end
Prepare the hook
In order to load images, we need client hooks. Let’s add our infinite_scroll.js
file:
// assets/js/infinite_scroll.js
export default InfiniteScroll = {
page() {return this.el.dataset.page;},
loadMore(entries) {
const target = entries[0];
if (target.isIntersecting && this.pending == this.page()) {
this.pending = this.page() + 1;
this.pushEvent("load-more", {});
}
},
mounted() {
this.pending = this.page();
this.observer = new IntersectionObserver(
(entries) => this.loadMore(entries),
{
root: null, // window by default
rootMargin: "400px",
threshold: 0.1,
}
);
this.observer.observe(this.el);
},
destroyed() {
this.observer.unobserve(this.el);
},
updated() {
this.pending = this.page();
},
};
There are at least 5 interesting things to note:
page()
function gets the value fromdata-page={@page}
attribute fromgallery_component.ex
.this.pushEvent("load-more", {});
calls ourhandle_event/3
function inindex.ex
.- The
root
,rootMargin
,threshold
options insidemounted()
function tells us where to find the target viewport, margin and the percentage of the target’s visibility before we load the next set of images. destroyed()
detaches from the observed element and move on to the next one.updated()
updates the new value of the page number.
And now we’re [hook]ing
Finally, let’s import infinite_scroll.js
file in assets/js/app.js
and attach it in our live socket.
// assets/js/app.js
...
import topbar from "../vendor/topbar"
import InfiniteScroll from "./infinite_scroll" // <-- import
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {
hooks: {InfiniteScroll}, // <-- add the hook!
params: {_csrf_token: csrfToken}
})
...
We did it! We created an infinite scrolling LiveView! The JavaScript hook linked a browser feature to our LiveView in fewer than 30 lines of JS code.
Our page should now look something like this:
Be sure to check out the demo and the complete source code!
Optionally Deploy it to Fly.io
You can deploy it yourself by following this guide, with the exception of adding Postgres (assuming you’re using CLI with fly launch
).
“To Infinity and Beyond!”
This solution works well for rendering uniformly shaped content. Aside from displaying images, this same feature could be used to show things like:
- Contacts
- Products
- Product reviews
- Client testimonials
- …and more!
What will you build with your infinitely scrolling LiveView?