Tutorial

Pagination with Phoenix LiveView

This post was updated 01 May - 2020

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

![](https://res.cloudinary.com/dwvh1fhcg/image/upload/v1579678301/tutorials/tut_6_img_0.png)

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

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:

![](https://res.cloudinary.com/dwvh1fhcg/image/upload/w_650,c_scale/v1579678301/tutorials/tut_6_img_1.png)

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

![](https://res.cloudinary.com/dwvh1fhcg/image/upload/w_650,c_scale/v1579678301/tutorials/tut_6_img_2.png)

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

![](https://res.cloudinary.com/dwvh1fhcg/image/upload/v1579678301/tutorials/tut_6_img_0.png)

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