Tutorial

Pagination with Phoenix LiveView

This post was updated 01 May - 2020

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

Final Result

Related Tutorials

Published 04 May - 2021
Updated 05 May - 2022

How to combine Phoenix LiveView with Alpine.js

No matter how great Phoenix LiveView is, there is still some use case for sprinking some JS in your app to improve UX. For example, tabs, dropdowns,..

Published 18 Oct - 2021

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