Tutorial
Pagination with Phoenix LiveView
This post was updated 01 May - 2020
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 load 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 productlistlive.ex
with the content:
defmodule TutorialWeb.ProductListLive do
use Phoenix.LiveView
alias Tutorial.Products
def mount(session, socket) do
assigns = [
conn: socket
]
{:ok, assign(socket, assigns)}
end
def render(assigns) do
TutorialWeb.ProductView.render("products.html", assigns)
end
end
Now is a good place to mention that the code inside mount
-function will run twice. First time, when the server render 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 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.
defmodule TutorialWeb.ProductListLive do
use Phoenix.LiveView
alias Tutorial.Products
def mount(session, socket) do
products = if connected?(socket), do: Products.list_products, else: []
assigns = [
conn: socket,
products: products
]
{:ok, assign(socket, assigns)}
end
def render(assigns) do
TutorialWeb.ProductView.render("products.html", assigns)
end
end
Now that the component has @products
available, I can just create the file /products/products.html.leex
and copy paste content from /products/index.html.eex
<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 "Show", to: Routes.product_path(@conn, :show, product) %></span>
<span class="text-sm mr-1"><%= link "Edit", to: Routes.product_path(@conn, :edit, product) %></span>
<span class="text-sm"><%= link "Delete", to: Routes.product_path(@conn, :delete, product), method: :delete, data: [confirm: "Are you sure?"] %></span>
</td>
</tr>
<% end %>
</tbody>
</table>
Then, in the file product/index.html.eex
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 "New Product", to: Routes.product_path(@conn, :new), class: "text-white hover:text-gray-200" %></span>
</div>
<%= live_render @conn, TutorialWeb.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.
defmodule Tutorial.Repo do
use Ecto.Repo, otp_app: :tutorial, 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/tutorial/products.ex
add:
def paginate_products(params \\ []) do
Product
|> Repo.paginate(params)
end
And then inside mount
in the ProductListLive
-component, I can now the paginates 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 visits 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 fallback to an empty %Scrivener.Page{}
if the component is not connected. Not that I also set default values so they are not nil
.
def mount(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
assigns = [
conn: socket,
products: entries,
page_number: page_number || 0,
page_size: page_size || 0,
total_entries: total_entries || 0,
total_pages: total_pages || 0
]
{:ok, assign(socket, assigns)}
end
def render(assigns) do
TutorialWeb.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 products.html.leex
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 has live_redirect
meaning that it can render a 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.html.eex
- template.
Open lib/tutorial_web/router.ex
and add:
...
live "/products", ProductListLive # NEEDS TO BE ABOVE
resources "/products", ProductController # WAS ALREADY IN THE ROUTER
If I do mix phx.routes
I can see the new route called live_path
:
live_path GET /products Phoenix.LiveView.Plug TutorialWeb.ProductListLive
This change bypass 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.html.eex file 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 "New Product", to: Routes.product_path(@conn, :new), class: "text-white hover:text-gray-200" %></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 "Show", to: Routes.product_path(@conn, :show, product) %></span>
<span class="text-sm mr-1"><%= link "Edit", to: Routes.product_path(@conn, :edit, product) %></span>
<span class="text-sm"><%= link "Delete", to: Routes.product_path(@conn, :delete, product), method: :delete, data: [confirm: "Are you sure?"] %></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 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}, _, socket) do
assigns = get_and_assign_page(page)
{:noreply, assign(socket, assigns)}
end
def handle_params(_, _, 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 the route is called live_path
.
def handle_event("nav", %{"page" => page}, socket) do
{:noreply, live_redirect(socket, to: Routes.live_path(socket, ProductListLive, page: page))}
end
The final version of the component now looks like:
defmodule TutorialWeb.ProductListLive do
use Phoenix.LiveView
alias TutorialWeb.Router.Helpers, as: Routes
alias TutorialWeb.ProductListLive
alias Tutorial.Products
def mount(_session, socket) do
{:ok, assign(socket, conn: socket)}
end
def render(assigns) do
TutorialWeb.ProductView.render("products.html", assigns)
end
def handle_event("nav", %{"page" => page}, socket) do
{:noreply, live_redirect(socket, to: Routes.live_path(socket, ProductListLive, page: page))}
end
def handle_params(%{"page" => page}, _, socket) do
assigns = get_and_assign_page(page)
{:noreply, assign(socket, assigns)}
end
def handle_params(_, _, socket) do
assigns = get_and_assign_page(nil)
{:noreply, assign(socket, assigns)}
end
def 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