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 implements sorting and pagination but in this tutorial, i will implement these datatable features with Phoenix LiveView.

The initial table looks like this. A table with customers that have several columns.

Sorting by column names

The first goal in the tutorial is to sort by columns by the column name. Also, I want to implement code that is reusable across different liveviews.

  1. Create a new helper module that contains the sorting logic
  2. Extent list_customers/1 function so it can take a second params argument
  3. Modify the CustomerLive Index to pass along the params to

The params that I will be working with here will have the shape of:

%{"sort_direction" => "asc", "sort_field" => "name"}

Step 1 - DataTable Module

In the data table module, I first want to add the sort/1 functions. I need to examine both field and direction and return a tuple with sort order and field. That tuble can then be passed in as a second argument in order_by/3 when doing the query.

# lib/tutorial_web/live/data_table.ex
defmodule TutorialWeb.Live.DataTable do
  import Phoenix.LiveView.Helpers

  def sort(%{"sort_field" => field, "sort_direction" => direction}) when direction in ~w(asc desc) do
    {String.to_atom(direction), String.to_existing_atom(field)}

  def sort(_other) do
    {:asc, :id}

NOTE that I use String.to_existing_atom(field) since I want to avoid that we dynamically create atoms based on user input.

Next step in the data table module is to add the table_link/3. That means that in the table-markup, inside the th-tag, I can add <%= table_link(@params, "Name", :name) %>

So, add these functions in the module:

# lib/tutorial_web/live/data_table.ex
def table_link(params, text, field) do
  direction = params["sort_direction"]

  opts =
    if params["sort_field"] == to_string(field) do
        sort_field: field,
        sort_direction: reverse(direction)
        sort_field: field,
        sort_direction: "desc"

  live_patch text, to: "?" <> querystring(params, opts), class: "link flex items-center"

defp querystring(params, opts \\ %{}) do
  params = params |> Plug.Conn.Query.encode() |> URI.decode_query()

  opts = %{
    "page" => opts[:page], # For the pagination
    "sort_field" => opts[:sort_field] || params["sort_field"] || nil,
    "sort_direction" => opts[:sort_direction] || params["sort_direction"] || nil

  |> Map.merge(opts)
  |> Enum.filter(fn {_, v} -> v != nil end)
  |> Enum.into(%{})
  |> URI.encode_query()

defp reverse("desc"), do: "asc"
defp reverse(_), do: "desc"

Step 2 - Implement the sorting

To make sorting work, I need to add list_customers/1 that take the params and use the sort/1 function to get the sort sirection and sort field.

# lib/tutorial/customers.ex
import TutorialWeb.Live.DataTable, only: [sort: 1]

def list_customers(params) do
    c in Customer,
    order_by: ^sort(params)
  ) |> Repo.all()

Step 3 - Update the live view

Last step here is to update the live view that is responsible to render the table. I need move the initial loading of customers (list_customers/0) in the mount-callback and instead move it to handle_params and call it with the params. So the relevant code now looks like this:

defmodule TutorialWeb.CustomerLive.Index do
  use TutorialWeb, :live_view
  import TutorialWeb.Live.DataTable

  def mount(_params, _session, socket) do
    {:ok, socket}

  def handle_params(params, _url, socket) do
      |> assign(:params, params)
      |> assign(:customers, list_customers(params))
      |> apply_action(socket.assigns.live_action, params)

  defp list_customers(params) do

NOTE That I used live_patch under the hood in the DataTable module. That means that it wont be a full page-refresh but it will directly call the handle_params/3 in the LiveView. That means that it has a much smaller impact and the UI feels much more responsive. Now I just need to implement the table_link inside the <th> tags.

<!-- lib/tutorial_web/live/customer_live/index.html.heex -->
<th><%= table_link(@params, "Name", :name) %></th>
<th><%= table_link(@params, "Address", :address) %></th>
<th><%= table_link(@params, "Zip", :zip) %></th>
<th><%= table_link(@params, "City", :city) %> </th>
<th><%= table_link(@params, "Phone", :phone) %></th>
<th><%= table_link(@params, "Longitude", :longitude) %></th>
<th><%= table_link(@params, "Latitude", :latitude) %></th>

And now, if I test it in the browser, it should work.

Pagination with Scrivener

The pagination library that I will use for this tutorial is Scrivener and more specifically ScrivenerEcto. I need to add it to the list of dependencies:

# mix.exs
defp deps do
    {:scrivener_ecto, "~> 2.0"},

And run the command

mix deps.get

To get started, I need to change the Repo.all/1 to Scrivener.paginate/2 in the list_customers/1. And I also set a default page_size here.

# lib/tutorial/customers.ex
@pagination [page_size: 10]

def list_customers(params) do
    c in Customer,
    order_by: ^sort(params)
  |> Scrivener.paginate(Scrivener.Config.new(Repo, @pagination, params))

Now, instead if a list, the function returns a Scrivener.Page struct with the entries and some info needed for pagination. The response will look something like this:

  entries: [
      address: "Adele Spring 47095",
      city: "White Plains",
      latitude: 41.033985,
      longitude: -73.762909,
      name: "O'Keefe-Kshlerin",
      phone: "6479788794",
      zip: "09483"
  page_number: 1,
  page_size: 10,
  total_entries: 101,
  total_pages: 11

Note that the page still works if I refresh it. The Scrivener.Page module has implemented a behaviour that makes it possible to loop over the entries just like it was a list, which is pretty neat.

Next, I need to create the pagination component. The component will loop through the pages from 1 to in this case 11, since it is 11 pages. I could use the @distance variable to set the number of page-links that will be visible at the same time.

There are some helper methods as well in the code below that helps me figure out what links to show, in regards to @distance and how to behave on the first and last page.

# lib/tutorial_web/live/pagination_component.ex
defmodule TutorialWeb.Live.PaginationComponent do
  use TutorialWeb, :live_component
  import TutorialWeb.Live.DataTable

  @distance 5

  def update(assigns, socket) do
      |> assign(assigns)
      |> assign(pagination_assigns(assigns[:pagination_data]))

  def render(assigns) do
    <div id={assigns[:id] || "pagination"} class="flex justify-center my-2">
      <%= if @total_pages > 1 do %>
        <div class="btn-group">
          <%= prev_link(@params, @page_number) %>
          <%= for num <- start_page(@page_number)..end_page(@page_number, @total_pages) do %>
            <%= live_patch num, to: "?#{querystring(@params, page: num)}", class: "btn btn-link #{if @page_number == num, do: "btn-active", else: ""}" %>
          <% end %>
          <%= next_link(@params, @page_number, @total_pages) %>
      <% end %>

  defp pagination_assigns(%Scrivener.Page{} = pagination) do
      page_number: pagination.page_number,
      page_size: pagination.page_size,
      total_entries: pagination.total_entries,
      total_pages: pagination.total_pages,

  def prev_link(conn, current_page) do
    if current_page != 1 do
      live_patch "Prev", to: "?" <> querystring(conn, page: current_page - 1), class: "btn btn-link"
      live_patch "Prev", to: "#", class: "btn btn-link btn-disabled"

  def next_link(conn, current_page, num_pages) do
    if current_page != num_pages do
      live_patch "Next", to: "?" <> querystring(conn, page: current_page + 1), class: "btn btn-link"
      live_patch "Next", to: "#", class: "btn btn-link btn-disabled"

  def start_page(current_page) when current_page - @distance <= 0, do: 1
  def start_page(current_page), do: current_page - @distance

  def end_page(current_page, 0), do: current_page
  def end_page(current_page, total)
       when current_page <= @distance and @distance * 2 <= total do
    @distance * 2
  def end_page(current_page, total) when current_page + @distance >= total do
  def end_page(current_page, _total), do: current_page + @distance - 1

NOTE I use live_patch here as well for the same reason that I did above. This prevents an entire page reload and just hits the handle_params.

<!-- lib/tutorial_web/live/customer_live/index.html.heex -->

<%= live_component TutorialWeb.Live.PaginationComponent, params: @params, pagination_data: @customers %>


Now that I test this in the browser the pagination should work as expected.

Note that the pagination preserves any sorting that I already have applied.

Related Tutorials

Published 02 Sep - 2020

Table sorting with Ecto and LiveView

A very common or even mandatory feature in e-commerce stores is the ability to sort a list of products by attributes. This is easy enough and a good..

Published 25 Nov
Updated 29 Nov
Phoenix 1.7

Sortable lists with Phoenix LiveView and SortableJS

A very common user interface pattern on the web is to have sortable elements. In this tutorial I will go through how to accomplish sortable lists wi..