Tutorial

Pagination with Phoenix LiveView

This post was updated 27 Mar

pagination liveview phoenix

Let’s say you have a long table that you want to paginate with Phoenix LiveView. In this tutorial, I have an existing table with 100 entries that I will use LiveView to paginate.

The reason that one would do that might be that the table loads slowly and you want to cut down on the initial rendering time.

Final Result

STEP 1 - Extract table to LiveView

First step is to create the LiveView component and move the table to that. I will call the component ProductListLive so create the file product_list_live.ex with the content:

# lib/my_app_web/live/product_list_live.ex
defmodule MyAppWeb.ProductListLive do
use MyAppWeb, :live_view
alias MyApp.Products
def mount(_params, _session, socket) do
{:ok, socket}
end
def render(assigns) do
MyAppWeb.ProductView.render("products.html", assigns)
end
end

Now is a good place to mention that the code inside the mount function will run twice. First time, when the server renders the component, and the second time when the component connects to the websocket. If we have a slow query, we might wait with loading until the socket is connected or just load a limited dataset. In this case, I will wait until the component is connected. So I can use connected?(socket):

products = if connected?(socket), do: Products.list_products(), else: []

And that gives me a list assigned to @products in the template.

# lib/my_app_web/live/product_list_live.ex
defmodule MyAppWeb.ProductListLive do
use MyAppWeb, :live_view
alias MyApp.Products
def mount(_params, _session, socket) do
products = if connected?(socket), do: Products.list_products(), else: []
{:ok, assign(socket, products: products)}
end
def render(assigns) do
MyAppWeb.ProductView.render("products.html", assigns)
end
end

Now that the component has @products available, I can just create the template file and copy paste content from the index template.

<table class="card-body table">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Price</th>
<th></th>
</tr>
</thead>
<tbody>
<%= for product <- @products do %>
<tr>
<td>{product.name}</td>
<td>{product.description}</td>
<td>{product.price}</td>
<td class="w-1/12 whitespace-no-wrap">
<span class="text-sm mr-1">
<.link navigate={~p"/products/#{product}"}>Show</.link>
</span>
<span class="text-sm mr-1">
<.link navigate={~p"/products/#{product}/edit"}>Edit</.link>
</span>
<span class="text-sm">
<.link href={~p"/products/#{product}"} method="delete" data-confirm="Are you sure?">Delete</.link>
</span>
</td>
</tr>
<% end %>
</tbody>
</table>

Then, in the index template I can now mount the LiveView component:

<div class="card mb-20">
<div class="card-header flex">
<h5 class="mb-0 flex-1">Listing Products</h5>
<span class="text-sm">
<.link navigate={~p"/products/new"} class="text-white hover:text-gray-200">New Product</.link>
</span>
</div>
{live_render(@conn, MyAppWeb.ProductListLive)}
</div>

If I start the server now, I should see my table again but it should load in a few microseconds after the initial rendering:

STEP 2 - Install Scrivener

Next step is to install the pagination library. Open mix.exs and add:

{:scrivener_ecto, "~> 2.0"}

Save and install by running:

mix deps.get

The only configuration you need to do is to add Scrivener inside the Repo module.

# lib/my_app/repo.ex
defmodule MyApp.Repo do
use Ecto.Repo, otp_app: :my_app, adapter: Ecto.Adapters.Postgres
use Scrivener, page_size: 10
end

This extends Repo with a paginate function. I will create a new function in my Products context below list_products. So, in the file lib/my_app/products.ex add:

def paginate_products(params \\ []) do
Product
|> Repo.paginate(params)
end

And then inside mount in the ProductListLive component, I can now use the paginated list.

products = if connected?(socket), do: Products.paginate_products().entries, else: []

Note that Products.paginate_products() returns a %Scrivener.Page{} struct where entries are the list of products.

If I start the server again and visit the list, it should now be only the 10 first entries.

STEP 3 - Display pagination

In this section I want to show what it takes to display the pagination links. First I extract the needed attributes from the Scrivener.Page and fall back to an empty %Scrivener.Page{} if the component is not connected. Note that I also set default values so they are not nil.

def mount(_params, _session, socket) do
%{entries: entries, page_number: page_number, page_size: page_size, total_entries: total_entries, total_pages: total_pages} =
if connected?(socket) do
Products.paginate_products()
else
%Scrivener.Page{}
end
{:ok,
assign(socket,
products: entries,
page_number: page_number || 0,
page_size: page_size || 0,
total_entries: total_entries || 0,
total_pages: total_pages || 0
)}
end
def render(assigns) do
MyAppWeb.ProductView.render("products.html", assigns)
end
def handle_event("nav", %{"page" => page}, socket) do
{:noreply, socket}
end

Above, I am also adding a handle_event("nav", %{"page" => page}, socket) function. That is needed for handling the phx-click events on the links.

Inside the products template below the table, I add the navigation code.

<nav class="border-t border-gray-200">
<ul class="flex my-2">
<li class="">
<a class={"px-2 py-2 #{if @page_number <= 1, do: "pointer-events-none text-gray-600"}"} href="#" phx-click="nav" phx-value-page={@page_number - 1}>Previous</a>
</li>
<%= for idx <- Enum.to_list(1..@total_pages) do %>
<li class="">
<a class={"px-2 py-2 #{if @page_number == idx, do: "pointer-events-none text-gray-600"}"} href="#" phx-click="nav" phx-value-page={idx}>{idx}</a>
</li>
<% end %>
<li class="">
<a class={"px-2 py-2 #{if @page_number >= @total_pages, do: "pointer-events-none text-gray-600"}"} href="#" phx-click="nav" phx-value-page={@page_number + 1}>Next</a>
</li>
</ul>
</nav>

With that in place, I have the pagination links under the table:

STEP 4 - Refactor and make live pagination work

When I click the links above, nothing happens at the moment. I just send events to an empty event handler. LiveView can navigate and re-render the component based on params that we can specify AND push the new params to the browser URL bar. However, to do this I need to mount the ProductListLive component in the router instead of in the index template.

Open lib/my_app_web/router.ex and add:

live "/products", ProductListLive # NEEDS TO BE ABOVE
resources "/products", ProductController # WAS ALREADY IN THE ROUTER

This change bypasses the controller for the index path and will render the live view component. Therefore we might just as well add the rest of the layout from the index template so we have the card as well.

<div class="card mb-20">
<div class="card-header flex">
<h5 class="mb-0 flex-1">Listing Products</h5>
<span class="text-sm">
<.link navigate={~p"/products/new"} class="text-white hover:text-gray-200">New Product</.link>
</span>
</div>
<table class="card-body table">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Price</th>
<th></th>
</tr>
</thead>
<tbody>
<%= for product <- @products do %>
<tr>
<td>{product.name}</td>
<td>{product.description}</td>
<td>{product.price}</td>
<td class="w-1/12 whitespace-no-wrap">
<span class="text-sm mr-1">
<.link navigate={~p"/products/#{product}"}>Show</.link>
</span>
<span class="text-sm mr-1">
<.link navigate={~p"/products/#{product}/edit"}>Edit</.link>
</span>
<span class="text-sm">
<.link href={~p"/products/#{product}"} method="delete" data-confirm="Are you sure?">Delete</.link>
</span>
</td>
</tr>
<% end %>
</tbody>
</table>
<nav class="border-t border-gray-200">
<ul class="flex my-2">
<li class="">
<a class={"px-2 py-2 #{if @page_number <= 1, do: "pointer-events-none text-gray-600"}"} href="#" phx-click="nav" phx-value-page={@page_number - 1}>Previous</a>
</li>
<%= for idx <- Enum.to_list(1..@total_pages) do %>
<li class="">
<a class={"px-2 py-2 #{if @page_number == idx, do: "pointer-events-none text-gray-600"}"} href="#" phx-click="nav" phx-value-page={idx}>{idx}</a>
</li>
<% end %>
<li class="">
<a class={"px-2 py-2 #{if @page_number >= @total_pages, do: "pointer-events-none text-gray-600"}"} href="#" phx-click="nav" phx-value-page={@page_number + 1}>Next</a>
</li>
</ul>
</nav>
</div>

When I mount a LiveView component in the router, I can utilize a new callback handle_params. So I can add these like this. In the first function, I can pattern match against URL parameters and see if something like ?page=1 is in the URL bar. If it is, it will match the first function. Otherwise, it will match the second one. The second one will be hit when we go to http://localhost:4000/products.

def handle_params(%{"page" => page}, _uri, socket) do
assigns = get_and_assign_page(page)
{:noreply, assign(socket, assigns)}
end
def handle_params(_params, _uri, socket) do
assigns = get_and_assign_page(nil)
{:noreply, assign(socket, assigns)}
end

When the pagination links are clicked, the function that handles the navigation now looks like this. Note that we use push_patch to update the URL and trigger handle_params.

def handle_event("nav", %{"page" => page}, socket) do
{:noreply, push_patch(socket, to: ~p"/products?page=#{page}")}
end

The final version of the component now looks like:

# lib/my_app_web/live/product_list_live.ex
defmodule MyAppWeb.ProductListLive do
use MyAppWeb, :live_view
alias MyApp.Products
def mount(_params, _session, socket) do
{:ok, socket}
end
def render(assigns) do
MyAppWeb.ProductView.render("products.html", assigns)
end
def handle_event("nav", %{"page" => page}, socket) do
{:noreply, push_patch(socket, to: ~p"/products?page=#{page}")}
end
def handle_params(%{"page" => page}, _uri, socket) do
assigns = get_and_assign_page(page)
{:noreply, assign(socket, assigns)}
end
def handle_params(_params, _uri, socket) do
assigns = get_and_assign_page(nil)
{:noreply, assign(socket, assigns)}
end
defp get_and_assign_page(page_number) do
%{
entries: entries,
page_number: page_number,
page_size: page_size,
total_entries: total_entries,
total_pages: total_pages
} = Products.paginate_products(page: page_number)
[
products: entries,
page_number: page_number,
page_size: page_size,
total_entries: total_entries,
total_pages: total_pages
]
end
end

Final Result

Related Tutorials

Published 18 Oct - 2021
Updated 27 Mar

Building a datatable in Phoenix LiveView

To display a static table on webpage that contains a lot of data is a pretty bad user experience. There are popular javascript libraries that implem..

NEW
Published 27 Mar

Adding Modals to Phoenix 1.8 with DaisyUI

In Phoenix 1.8, the built-in modal component was removed. Instead, Phoenix now encourages developers to use separate LiveView pages for new and edit..