Infinitely Scroll Images in LiveView · The Phoenix Files

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:

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
  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:

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:

Initial state and event handle

The code below is where we have our initial state and a function (load-more) getting the next set of images.

# 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:

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:

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:

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 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.

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:

What will you build with your infinitely scrolling LiveView?

Fly ❤️ Elixir

Fly is an awesome place to run your Elixir apps. Deploying, clustering, and more is supported!