DEV Community

Cover image for Efficient bidirectional infinite scroll in Phoenix LiveView
Christian Alexander
Christian Alexander

Posted on

Efficient bidirectional infinite scroll in Phoenix LiveView

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.

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

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

Setup

To demonstrate the implementation of this project, I've created a new LiveView application that contains a simplified data model that simulates what had to be built in Deliver. It uses a background process (a GenServer) to create events and publish them over Phoenix PubSub, 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).

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
Enter fullscreen mode Exit fullscreen mode

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). Additional alerts are added to the existing ones in a large list, assigned to the socket and rendered in the table.

--- 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
Enter fullscreen mode Exit fullscreen mode

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)

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
Enter fullscreen mode Exit fullscreen mode

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

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

@@ -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>
Enter fullscreen mode Exit fullscreen mode

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:

@@ -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
Enter fullscreen mode Exit fullscreen mode

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)

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

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)

@@ -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>
Enter fullscreen mode Exit fullscreen mode

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

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.

Hooks allow the client to execute JavaScript functions during the lifecycle of any element with a corresponding phx-hook attribute. (Link to related commit)

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.

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);
      },
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

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:

@@ -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
Enter fullscreen mode Exit fullscreen mode

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

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.

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 if you have a webhook system).

Oldest comments (3)

Collapse
 
miguelcoba profile image
Miguel Cobá

Excellent explanation, Christian. Today I learned something new. Thanks

Collapse
 
jaeyson profile image
Jaeyson Anthony Y.

Hi Christian quick question: how did you make the markdown make diffs and highlight it? seems like I can't do what you did here. Great topic btw!

Collapse
 
christianalexander profile image
Christian Alexander • Edited

@jaeyson, to specify a language in a markdown code block, you have to put a language immediately after the opening triple ticks.

In the case of diffs, the language should be “diff”.

By specifying this option, most markdown sites (including dev.to) should be able to understand the format. Hope that helps!