Efficient bidirectional infinite scroll in Phoenix LiveView
Tags: elixir, phoenix, liveview
Published: 2022-10-14 13:35:30

---
title: Efficient bidirectional infinite scroll in Phoenix LiveView
description: Implementing an infinitely-scrolling page that streams in new events.
published: true
tags: [elixir, phoenix, liveview]
cover_image: https://dev-to-uploads.s3.amazonaws.com/uploads/articles/azdazrrze8u96d3hnhqs.png
---

In this post, I'll share the progression of my implementation of an infinite scrolling UI in Phoenix LiveView—from naive to efficient. Follow along with the example implementation in [this repo](https://github.com/ChristianAlexander/liveview_bidirectional_scroll_demo).

## Context

Phoenix LiveView makes it easy to create interactive web apps without having to write much (if any) frontend code. The server sends diffs over a websocket to update the client's UI. Data can be presented to the user without page refreshes or polling.

A few weeks ago, I set out to add an alert list page to Deliver, a webhook monitoring system I've been building ([check it out here](https://deliver.dev)). 

As the page is scrolled, older alerts are loaded into the table. Critically, new alerts are added to the top of the page as they occur, backed by a Phoenix PubSub subscription.

![Screenshot from Deliver](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/24zgtiwjejnnhndxygw7.png)

## Setup
To demonstrate the implementation of this project, I've created [a new LiveView application](https://github.com/ChristianAlexander/liveview_bidirectional_scroll_demo) that contains a simplified data model that simulates what had to be built in Deliver. It uses a [background process](https://github.com/ChristianAlexander/liveview_bidirectional_scroll_demo/commit/57fe82366c28bf3c6d6f564387401ba6ba3b9011) (a [GenServer](https://hexdocs.pm/elixir/1.14/GenServer.html)) to create events and publish them over [Phoenix PubSub](https://hexdocs.pm/phoenix_pubsub/Phoenix.PubSub.html), storing the events in a database for later retrieval.

## Attempt 1: The Memory Hog
The first implementation of the alerts page was really easy to implement, but it kept on using more and more memory during sustained sessions.

First, I created a simple table view that loaded five alerts from the `alerts` table ([link to related commit](https://github.com/ChristianAlexander/liveview_bidirectional_scroll_demo/commit/6af99f99057488bd8383d9e0657036f4bd0bedd6)).

```elixir
defmodule BidirectionalScrollWeb.Live.ScrollDemoLive do
  use BidirectionalScrollWeb, :live_view

  alias BidirectionalScroll.Alerts

  def mount(_params, _session, socket) do
    alerts = Alerts.list_alerts(limit: 5)

    socket = assign(socket, alerts: alerts)

    {:ok, socket}
  end

  def render(assigns) do
    ~H"""
    <h1>Alerts</h1>
    <table>
      <thead>
        <tr>
          <th>ID</th>
          <th>Started At</th>
          <th>Resolved At</th>
        </tr>
      </thead>
      <tbody>
        <%= for alert <- @alerts do %>
          <tr>
            <td><%= alert.id %></td>
            <td><%= alert.started_at %></td>
            <td><%= alert.resolved_at %></td>
          </tr>
        <% end %>
      </tbody>
    </table>
    """
  end
end
```

Next, I added a "Load More" button to the bottom of the page, using `phx-click` and a `handle_event` callback ([link to related commit](https://github.com/ChristianAlexander/liveview_bidirectional_scroll_demo/commit/a27f6c82effbc48713911a56961a378691326c41)). Additional alerts are added to the existing ones in a large list, assigned to the socket and rendered in the table.

```diff
--- a/lib/bidirectional_scroll_web/live/scroll_demo_live.ex
+++ b/lib/bidirectional_scroll_web/live/scroll_demo_live.ex
@@ -11,6 +11,18 @@ defmodule BidirectionalScrollWeb.Live.ScrollDemoLive do
     {:ok, socket}
   end
 
+  def handle_event("load-more", _value, socket) do
+    alerts = socket.assigns.alerts
+
+    oldest_loaded_alert = Enum.min_by(alerts, & &1.started_at, NaiveDateTime)
+    older_alerts = Alerts.list_alerts(started_before: oldest_loaded_alert.started_at, limit: 5)
+
+    alerts = alerts ++ older_alerts
+    socket = assign(socket, alerts: alerts)
+
+    {:noreply, socket}
+  end
+
   def render(assigns) do
     ~H"""
     <h1>Alerts</h1>
@@ -32,6 +44,7 @@ defmodule BidirectionalScrollWeb.Live.ScrollDemoLive do
         <% end %>
       </tbody>
     </table>
+    <button phx-click="load-more">Load More</button>
     """
   end
 end
```

Finally, I wired up Phoenix PubSub to broadcast when alerts are created and updated. The subscription in the scrolling UI causes two `handle_info` callbacks to be invoked. When `:alert_created` is received, the new alert is prepended to the list that is assigned to the socket. `:alert_updated` uses `Enum.map` to update the matching alert by ID. ([Link to related commit](https://github.com/ChristianAlexander/liveview_bidirectional_scroll_demo/commit/49adc52e344057821b7a9267e5a31c36de0ada9a))

```elixir
def handle_info({:alert_created, alert}, socket) do
    alerts = socket.assigns.alerts

    alerts = [alert | alerts]
    socket = assign(socket, alerts: alerts)

    {:noreply, socket}
  end

  def handle_info({:alert_updated, %{id: alert_id} = alert}, socket) do
    alerts = socket.assigns.alerts

    alerts =
      Enum.map(alerts, fn
        %{id: ^alert_id} -> alert
        a -> a
      end)

    socket = assign(socket, alerts: alerts)

    {:noreply, socket}
  end
```

At this point, the alerts page _technically_ works. However, in production this will end up consuming tons of memory. Every connected session will retain a copy of all alerts in an ever-increasing list, never being garbage collected.

![initial implementation working](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5g322neig5tjpj274mxr.gif)

This is one of the big downsides of naive Phoenix LiveView implementations: in order to automatically produce efficient diffs to send to the browser, the server must keep its assigned values in the socket so it can re-render.

**Luckily, this is not the only way to build a list view.**

## Attempt 2: Out of Order
Phoenix LiveView has a concept called [temporary assigns](https://hexdocs.pm/phoenix_live_view/dom-patching.html) to solve the memory consumption issue encountered in the first attempt.

To use temporary assigns, we have to add a `phx-update` attribute to the list's container, along with providing a DOM ID to uniquely identify the container on the page. This allows the LiveView client script to prepend and append to the container's contents. Each child element also requires an ID to perform updates on items that have already been rendered. ([Link to related commit](https://github.com/ChristianAlexander/liveview_bidirectional_scroll_demo/commit/913683f56c694298048d4ecf54b0bdba1a668f80))

```diff
@@ -61,9 +48,9 @@ defmodule BidirectionalScrollWeb.Live.ScrollDemoLive do
           <th>Resolved At</th>
         </tr>
       </thead>
-      <tbody>
+      <tbody id="alert-list" phx-update="prepend">
         <%= for alert <- @alerts do %>
-          <tr>
+          <tr id={"alert-#{alert.id}"} >
             <td><%= alert.id %></td>
             <td><%= alert.started_at %></td>
             <td><%= alert.resolved_at %></td>
```

For now, we are hard-coding `phx-update` to `prepend`. Any alert that hasn't yet been received by the frontend will be added to the top of the list.

Along with this `render` update, we have to start using temporary assigns:

```diff
@@ -6,33 +6,20 @@ defmodule BidirectionalScrollWeb.Live.ScrollDemoLive do  
   def mount(_params, _session, socket) do
     if connected?(socket) do
       Phoenix.PubSub.subscribe(BidirectionalScroll.PubSub, "alerts")
     end
     alerts = Alerts.list_alerts(limit: 5)

     socket = assign(socket, alerts: alerts)
 
-    {:ok, socket}
+    {:ok, socket, temporary_assigns: [alerts: []]}
   end
 
   def handle_info({:alert_created, alert}, socket) do
-    alerts = socket.assigns.alerts
-
-    alerts = [alert | alerts]
-    socket = assign(socket, alerts: alerts)
-
+    socket = assign(socket, alerts: [alert])
     {:noreply, socket}
   end
 
-  def handle_info({:alert_updated, %{id: alert_id} = alert}, socket) do
-    alerts = socket.assigns.alerts
-
-    alerts =
-      Enum.map(alerts, fn
-        %{id: ^alert_id} -> alert
-        a -> a
-      end)
-
-    socket = assign(socket, alerts: alerts)
-
+  def handle_info({:alert_updated, alert}, socket) do
+    socket = assign(socket, alerts: [alert])
     {:noreply, socket}
   end
```

Adding the `temporary_assigns` to the mount response causes the assigns with corresponding keys to be reset to the default value after every render. In this case, we reset `socket.assigns.alerts` to `[]` after every render. By resetting the value, we allow the runtime to garbage collect the `Alert` instances that were being referenced as members of the `alerts` list.

This update ends up simplifying the `handle_info` callback implementations. Since a render runs every time a callback returns, we can set the `alerts` list to a list only containing the new or updated value. In the case of `:alert_created`, there will be no element with a matching ID and it will follow the `phx-update` behavior—in this case prepending to the list. As for `:alert_updated`, a matching element should be found in the DOM. The matching element will simply be replaced.

The "Load More" button causes the LiveView process to crash now, since its previous implementation looked at the list of assigned alerts to figure out the `started_at` value of the earliest alert on the page. Since the list is empty, we have no record of the oldest loaded alert. To fix this, we can add (and maintain) a single datetime in the socket. ([Link to related commit](https://github.com/ChristianAlexander/liveview_bidirectional_scroll_demo/commit/29a623d8a5d7d66857084a56d4feead6f24ddf39))

At this point, the page prepends items as expected, but the "Load More" button ends up prepending values that should be appended.

![out of order](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/0it1an833d0jrwyvp6os.gif)

## Attempt 3: It Works Again
At the end of the second attempt, we had an implementation that was close to feature parity with the naive memory hog, but it had one major flaw: we were only prepending to the list.

The update behavior is controlled by the `phx-update` attribute of the container element. There's nothing keeping us from setting this to `append` or `prepend` dynamically. It turns out that the solution to this problem is quite simple:
([Link to relevant commit](https://github.com/ChristianAlexander/liveview_bidirectional_scroll_demo/commit/2c299aa3a72bf5a3a7d3ad0b5ce463739036536d))

```diff
@@ -6,28 +6,39 @@ defmodule BidirectionalScrollWeb.Live.ScrollDemoLive do  
   def mount(_params, _session, socket) do
     if connected?(socket) do
       Phoenix.PubSub.subscribe(BidirectionalScroll.PubSub, "alerts")
     end
     alerts = Alerts.list_alerts(limit: 5)
     oldest_loaded_alert = Enum.min_by(alerts, & &1.started_at, NaiveDateTime)
 
     socket =
       assign(socket,
         alerts: alerts,
-        oldest_alert_started_at: oldest_loaded_alert.started_at
+        oldest_alert_started_at: oldest_loaded_alert.started_at,
+        update_direction: "append"
       )
 
     {:ok, socket, temporary_assigns: [alerts: []]}
   end
 
   def handle_info({:alert_created, alert}, socket) do
-    socket = assign(socket, alerts: [alert])
+    socket =
+      assign(socket,
+        alerts: [alert],
+        update_direction: "prepend"
+      )
+
     {:noreply, socket}
   end
 
   def handle_info({:alert_updated, alert}, socket) do
-    socket = assign(socket, alerts: [alert])
+    socket =
+      assign(socket,
+        alerts: [alert],
+        update_direction: "prepend"
+      )
+
     {:noreply, socket}
   end
 
@@ -34,13 +45,14 @@ defmodule BidirectionalScrollWeb.Live.ScrollDemoLive do
   def handle_event("load-more", _value, socket) do
     oldest_alert_started_at = socket.assigns.oldest_alert_started_at
     alerts = Alerts.list_alerts(started_before: oldest_alert_started_at, limit: 5)
     oldest_loaded_alert = Enum.min_by(alerts, & &1.started_at, NaiveDateTime)
 
     socket =
       assign(socket,
         alerts: alerts,
-        oldest_alert_started_at: oldest_loaded_alert.started_at
+        oldest_alert_started_at: oldest_loaded_alert.started_at,
+        update_direction: "append"
       )
 
     {:noreply, socket}
@@ -57,7 +69,7 @@ defmodule BidirectionalScrollWeb.Live.ScrollDemoLive do
           <th>Resolved At</th>
         </tr>
       </thead>
-      <tbody id="alert-list" phx-update="prepend">
+      <tbody id="alert-list" phx-update={@update_direction}>
         <%= for alert <- @alerts do %>
           <tr id={"alert-#{alert.id}"} >
             <td><%= alert.id %></td>
```

In this change, we bind the `phx-update` attribute of the `alert-list` container element to the assigns value of `update_direction`. Whenever we receive a new alert, we set the direction to `append`. Whenever the "Load More" button is clicked, we set it to `append`.

We now have a working implementation that matches the first attempt, but with substantially less per-connection memory consumption on the server side.

![it works again](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/u5utabfwb2dyj1na3mu7.gif)

## Bonus: Automatically Load More

Most infinite scrolling implementations don't require the user to click a button to load more content. We can do the same using [LiveView client hooks](https://hexdocs.pm/phoenix_live_view/js-interop.html#client-hooks-via-phx-hook).

Hooks allow the client to execute JavaScript functions during the lifecycle of any element with a corresponding `phx-hook` attribute. ([Link to related commit](https://github.com/ChristianAlexander/liveview_bidirectional_scroll_demo/commit/c82d40e62709529ffdc8a2a9c2b86632c9ccfe2e))

In this case, we will add a hook named `InfiniteScrollButton` that will simulate a click of the button whenever it enters the browser's viewport. This code replaces the existing `new LiveSocket` call in `assets/js/app.js`.

```javascript
let liveSocket = new LiveSocket("/live", Socket, {
  params: { _csrf_token: csrfToken },
  hooks: {
    InfiniteScrollButton: {
      loadMore(entries) {
        const target = entries[0];
        if (target.isIntersecting && !this.el.disabled) {
          this.el.click();
        }
      },
      mounted() {
        this.observer = new IntersectionObserver(
          (entries) => this.loadMore(entries),
          {
            root: null, // window by default
            rootMargin: "0px",
            threshold: 1.0,
          }
        );
        this.observer.observe(this.el);
      },
      beforeDestroy() {
        this.observer.unobserve(this.el);
      },
    },
  },
});
```

Finally, we have to add the `phx-hook` attribute (and a DOM ID to help with tracking) to the rendered "Load More" button to cause it to click itself whenever it enters the page:

```diff
@@ -79,7 +79,7 @@ defmodule BidirectionalScrollWeb.Live.ScrollDemoLive do
         <% end %>
       </tbody>
     </table>
-    <button phx-click="load-more">Load More</button>
+    <button id="alerts-load-more" phx-hook="InfiniteScrollButton" phx-click="load-more">Load More</button>
     """
   end
 end
```

That's it! We now have a memory-efficient, bidirectional infinite scrolling view that automatically keeps elements up-to-date whenever they are sent over PubSub.

![Final](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3m0wk2fgc785qkddixjz.gif)

## Conclusion
Phoenix LiveView makes it simple to build applications that work. When performance and scaling issues come up, escape hatches such as temporary assigns and the client library can save the day. As a final step, interactivity can be added through hooks and (not seen here) [JS commands](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.JS.html).

LiveView is easily the most productive full-stack solution I've ever encountered, and it's a joy to get to use it in projects like Deliver (which, again, you should [check out](https://deliver.dev) if you have a webhook system).