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.
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
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
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
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.
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>
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
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.
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>
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.
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);
},
},
},
});
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
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.
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).
Top comments (3)
Excellent explanation, Christian. Today I learned something new. Thanks
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!
@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!