Interactive data tables in Phoenix Liveview

Interactive Data Tables in Phoenix LiveView: AG Grid Integration Made Simple

Building feature-rich data tables remains one of web development's persistent challenges.

Table of contents

    Introduction

    While Phoenix LiveView provides basic table functionality, enterprise applications demand more: advanced filtering, dynamic column management, and rich interactive capabilities. Building these features from scratch can consume days of development time.

    This article shows how to combine AG Grid, a powerful JavaScript table library, with Phoenix LiveView to create enterprise-grade tables. You'll learn how LiveView's WebSocket-based communication and AG Grid's comprehensive features work together to deliver sophisticated, real-time data tables with minimal code.

    You will learn to:

    • Set up AG Grid in your LiveView application
    • Implement advanced table features with minimal code
    • Maintain clean separation between Elixir and JavaScript
    • Use custom formatting and column types
    • Keep the benefits of LiveView's server-side rendering

    Livebook Demo

    This article includes a Livebook with a complete working demo of the concepts discussed. The LiveBook allows you to experiment with the code interactively and see the results in real-time.

    Livebook

    Table Views in Phoenix Web Apps

    Despite the evolution of web frameworks, implementing comprehensive table functionality remains a significant challenge. Phoenix developers often find themselves:

    • Repeatedly implementing basic features like sorting and pagination
    • Wrestling with complex filtering logic for different data types
    • Building custom column management systems
    • Optimizing performance for large datasets
    • Managing state across multiple table features

    Enter AG Grid: The Power Grid for LiveView

    AG Grid has established itself as the industry standard for JavaScript data grids, offering a comprehensive solution for complex data presentation needs.

    The library comes in two flavors:

    • Community Edition (free)
    • Enterprise Edition (licensed)

    This article focuses on features available in the Community Edition, demonstrating how even the free version can significantly enhance your application's capabilities.

    You can find more about the AG Grid on the official website: https://www.ag-grid.com/

    Key Features (Community Edition):

    • High-performance rendering engine
    • Row Pagination
    • Advanced sorting and filtering
    • Dynamic column management (pinning, resizing)
    • Custom cell rendering
    • Data export capabilities (CSV)

    Installation

    In your Phoenix project directory run:

    cd ./assets
    npm install ag-grid-community

    Quick Start: Basic Integration

    Let's build a table showcasing world's largest metropolises. With just a few components, we'll create a fully interactive table supporting sorting, filtering, and column management.

    Interactive Data Tables in Phoenix LiveView-1

    1. LiveView Setup

    def render(assigns) do
      ~H"""
      <div id="myGrid"
           phx-hook="GridHook"
           phx-update="ignore"
           style="height: 500px">
      </div>
      """
    end
    

    Note the important attributes:

    • phx-hook="GridHook": Connects our JavaScript code with LiveView (to learn about the hooks and their role, click here)
    • phx-update="ignore": Prevents LiveView from updating DOM managed by AG Grid

    2. JavaScript Hook Configuration

    In your assets/js/app.js, set up the hook that will initialize AG Grid:

    let Hooks = {}
    
    Hooks.GridHook = {
      mounted() {
        this.handleEvent("load-grid", (gridOptions) => {
          agGrid.createGrid(
            document.querySelector("#myGrid"),
            gridOptions
          )
        })
      }
    }
    
    let liveSocket = new LiveSocket("/live", Socket, {
      hooks: Hooks,
      // ... other options
    })
    

    This hook listens for the load-grid event from LiveView and initializes AG Grid with the provided configuration.

    3. Grid Configuration in LiveView

    Here's where LiveView shines - we can define our entire initial grid configuration in Elixir:

    def handle_params(_params, _uri, socket) do
      gridOptions = %{
        columnDefs: column_defs(),
        rowData: list_records(),
        defaultColDef: %{
          filter: true,
          sortable: true,
          resizable: true
        }
      }
    
      socket = push_event(socket, "load-grid", gridOptions)
      {:noreply, socket}
    end
    
    defp column_defs do
      [
        %{field: "continent"},
        %{field: "country"},
        %{field: "city"},
        %{field: "population"}
      ]
    end
    
    defp list_records do
      [
        %{
          city: "Tokyo",
          country: "Japan",
          continent: "Asia",
          population: 37_400_068
        },
        # ... more cities
      ]
    end
    

    Key points about this setup:

    • columnDefs defines the structure of our table
    • rowData contains the actual data
    • defaultColDef applies settings to all columns
    • gridOptions configuration format understood by the AG Grid
    • push_event sends the gridOption object to the GridHook (frontend)

    The basic AG Grid setup is possible via a JSON object composed of literals (strings, numbers, etc.). We can define such setup in Elixir because LiveView automatically converts Elixir maps to JSON objects when sending custom data to the frontend.

    By now, with this minimal setup, you get a powerful table with:

    • Sortable columns
    • Built-in filtering
    • Column resizing
    • Responsive layout

    Beyond Basics: Data Presentation Magic

    Now that we have our basic table working, let's enhance it with more advanced features. In the following examples we will be modifying the way the cells are rendered to the user.

    Smart Number Formatting

    Large numbers like population figures can be hard to read in their raw form (e.g., "37400068"). Let's make them more user-friendly with thousand separators.

    # Before: Population display
    37400068
    
    # After: Formatted display
    37,400,068

    Here's how the table looks with thousand separators applied (take a close look at the previous screenshot for comparison):

    Interactive Data Tables in Phoenix LiveView-2

    Let’s explore how this can be achieved with out setup.

    The Column Types Pattern

    AG Grid provides two ways to format cell values via custom JavaScript functions:

    1. Value Formatters - for text-only transformations
    2. Cell Renderers - for HTML/DOM manipulation inside the cell (i.e. add images)

    But there's a challenge: we can't define JavaScript functions directly in our Elixir code. Here's where AG Grid's column types feature comes to the rescue.

    Instead of defining formatters directly in column definitions, we can:

    1. Define reusable column types in JavaScript
    2. Reference these types by name in our Elixir code

    Here's how to implement thousand separators with the Column Types pattern:

    // assets/js/app.js
    const ColumnTypes = {
      numericThousandSeparator: {
        valueFormatter: (params) =>
          new Intl.NumberFormat("en-US").format(params.value),
      }
    }
    
    Hooks.GridHook = {
      mounted() {
        this.handleEvent("load-grid", (serverOptions) => {
          const gridOptions = {
            columnTypes: ColumnTypes,
            ...serverOptions
          }
          agGrid.createGrid(document.querySelector("#myGrid"), gridOptions)
        })
      }
    }
    

    Then in your LiveView, simply reference the formatter by name:

    defp column_defs do
      [
        %{field: "continent"},
        %{field: "country"},
        %{field: "city"},
        %{
          field: "population",
          type: ["numericThousandSeparator"]  # Reference the formatter by name
        }
      ]
    end
    

    Interactive Cells: From Data to HTML/DOM

    Sometimes plain text isn't enough - we need clickable elements, buttons, or complex layouts within table cells. Let's create interactive airport buttons that open flight information in a new tab:

    Interactive Data Tables in Phoenix LiveView-3

    We have airport codes in our data as arrays:

    %{
      city: "Tokyo",
      airports: ["HND", "NRT"]  # Haneda and Narita airports
    }
    

    We want to transform this into clickable buttons, but we face two challenges:

    1. We need to create DOM elements, not just format text
    2. We want to maintain consistent styling and behavior

    While value formatters return strings, cell renderers return DOM elements. We'll use the same column types pattern but with cellRenderer instead of valueFormatter:

    const ColumnTypes = {
      // ... previous types
      airport_links: {
        cellRenderer: (params) => {
          if (!Array.isArray(params.value)) return params.value;
    
          const container = document.createElement("div");
          container.className = "flex flex-row space-x-2";
    
          params.value?.forEach((code) => {
            const button = document.createElement("button");
            button.className =
              "px-2 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm";
    
            const link = document.createElement("a");
            link.href = `https://www.google.com/search?q=${code} airport`;
            link.target = "_blank";
            link.textContent = code;
    
            button.appendChild(link);
            container.appendChild(button);
          });
    
          return container;
        }
      }
    }
    

    In your LiveView, reference this renderer:

    defp column_defs do
      [
        # ... other columns
        %{
          field: "airports",
          headerName: "Airports",
          type: ["airport_links"],
          width: 150
        }
      ]
    end
    

    Smart Sorting: Beyond Basic Comparisons

    When working with complex data types like arrays, AG Grid's default sorting might not provide the behavior we want. For example, our airports column contains arrays of airport codes, but sorting alphabetically by array content isn't very useful.

    Let's implement a more intuitive sorting - by the number of airports each city has:

    Interactive Data Tables in Phoenix LiveView-4

    We'll use the Column Types pattern again, this time implementing a custom comparator that sorts by the number of airports (comparing the length of each record's airports array):

    const ColumnTypes = {
      // ... previous types
      array_length_comparator: {
        comparator: (valueA, valueB) => {
          if (!valueA || !valueB) return 0;
          if (Array.isArray(valueA) && Array.isArray(valueB)) {
            return valueA.length - valueB.length;
          }
          return valueA - valueB;
        },
      },
    };

    Now we can combine multiple column types in our LiveView - one for rendering the buttons and another for sorting:

    defp column_defs do
      [
        # ... other columns
        %{
          field: "airports",
          headerName: "Airports",
          type: ["airport_links", "array_length_comparator"],  # Multiple types
          cellDataType: false,
          width: 150
        }
      ]
    end
    

    Clicking the column header now sorts cities by their number of airports. Cities with more airports appear at the top when sorted in descending order.

    Benefits of the Cell Type Pattern:

    • Keep JavaScript code organized and reusable
    • Maintain clean separation between Elixir and JavaScript
    • Reference complex JavaScript functionality with simple string identifiers
    • Easy to add new formatting types without changing Elixir code

    Working with Ecto: Handling Complex Data Structures

    In real Phoenix applications, we typically work with Ecto schemas rather than plain maps. These often include nested associations and embedded schemas. Let's see how to handle this complexity effectively with AG Grid.

    Let's enhance our cities data with some economic indicators stored in an embedded schema:

    defmodule Cities.Indices do
      defstruct [:cpi_score, :expat_cost_score, :hdi_score]
    end
    
    defmodule Cities.City do
      defstruct [:city, :country, :continent, :population, :indices, :airports]
    end
    
    # Example data structure
    %City{
        city: "Tokyo",
        country: "Japan",
        continent: "Asia",
        population: 37_400_068,
        airports: ["HND", "NRT"],
        indices: %Indices{
          cpi_score: "78.3",
          expat_cost_score: "93.5",
          hdi_score: "0.925"
        }
    }
    

    Working with such structures presents three main challenges:

    1. Accessing Nested Fields: How to display data from nested structures
    2. JSON Serialization: Ecto structs don't implement the JSON.Encoder protocol by default, so they can't be directly sent over LiveView
    3. Security: How to send only the necessary data to the frontend

    Let's tackle each challenge.

    Accessing Nested Fields

    AG Grid supports “dot notation” in column definitions for accessing nested fields.

    defp column_defs do
      [
        # ... other columns
        %{
          headerName: "Expat Cost Score",
          field: "indices.expat_cost_score"  # Dot notation for nested access
        }
      ]
    end

    We can see the nested fields in the resulting table:

    Interactive Data Tables in Phoenix LiveView-5

    This works seamlessly with nested data in JSON objects sent from the LiveView. While nested maps are handled seamlessly in LiveView, there is a fundamental issue with JSON serialization of structs, including Ecto schemas. As we will see - maybe for the better.

    JSON Serialization & Security

    While we could derive the Jason.Encoder protocol for our structs:

    # This works but isn't ideal
    defmodule Cities.City do
      @derive {Jason.Encoder, only: [:city, :country, :population]}
      defstruct [:city, :country, :continent, :population, :indices, :airports]
    end
    

    This approach:

    • Mixes presentation concerns with data models
    • Makes it easy to accidentally expose sensitive data
    • Requires protocol implementation for every struct

    A Better Solution: The Data Extract Helper

    Instead, we can use a helper that both converts structs to maps (solving serialization) and selectively extracts only needed fields (ensuring security):

    @doc """
    Extracts and restructures fields from nested maps/structs based on dot-separated paths.
    
    ## Example:
        records = [%City{indices: %Indices{score: 90}}]
        paths = ["city", "indices.score"]
        extract(records, paths)
        > [%{city: "Tokyo", indices: %{score: 90}}]
    """
    def extract(records, paths) do
      access_paths =
        for path <- paths do
          path
          |> String.split(".")
          |> Enum.map(&String.to_atom/1)
          |> List.foldr([], fn
            key, [] -> [Access.key(key)]
            key, path -> [Access.key(key, %{}) | path]
          end)
        end
    
      for record <- records do
        Enum.reduce(access_paths, %{}, fn path, acc ->
          put_in(acc, path, get_in(record, path))
        end)
      end
    end
    

    Let’s modify our LiveView to extract only needed fields given in columnDefs using the “dot notation”:

    def handle_params(_params, _uri, socket) do
      # Collect all field paths from column definitions
      field_paths = Enum.map(column_defs(), & &1.field)
    
     
      filtered_records =
        list_records()
        |> extract(field_paths) # Extract only needed fields
    
      gridOptions = %{
        columnDefs: column_defs(),
        rowData: filtered_records,
        defaultColDef: %{filter: true}
      }
    
      socket = push_event(socket, "load-grid", gridOptions)
      {:noreply, socket}
    end
    

    Verifying the Result

    Let's check the actual data sent to the frontend using browser's Developer Tools:

    Interactive Data Tables in Phoenix LiveView-6

    Notice that only the fields referenced in our column definitions are transmitted, maintaining both security and performance.

    Benefits of the Data Extract approach:

    • Security: Only explicitly requested fields are exposed
    • Performance: Minimal payload size
    • Separation of Concerns: Data models remain clean
    • Flexibility: Easy to adjust displayed fields
    • Maintainability: Single source of truth for field selection

    Conclusions

    This article demonstrated how to create powerful interactive tables using AG Grid Community Edition and Phoenix LiveView.

    Key takeaways:

    • Keep JavaScript code organized through the Column Types pattern
    • Use the data Data Extract for secure and efficient data transmission
    • Maintain clean separation between Elixir and JavaScript code
    • Leverage LiveView's JSON serialization capabilities

    Coming next

    Stay tuned for our next article on AG Grid Enterprise edition and Phoenix LiveView, where we'll explore advanced features including:

    • Server-Side Rendering with Pagination
    • Row Grouping
    • Pivot and Aggregate Functions

    FAQ

    What is AG Grid, and why integrate it with Phoenix LiveView?

    AG Grid is a powerful JavaScript data grid library that offers advanced features like sorting, filtering, and dynamic column management. Integrating it with Phoenix LiveView allows developers to create feature-rich, interactive data tables efficiently.

    How do you set up AG Grid in a Phoenix LiveView application?

    To set up AG Grid in a Phoenix LiveView application, install the AG Grid package in your project's assets directory and configure a LiveView component with the necessary JavaScript hooks to initialize and manage the grid.

    What are the benefits of using AG Grid's Community Edition?

    The Community Edition of AG Grid provides high-performance rendering, advanced sorting and filtering, dynamic column management, custom cell rendering, and data export capabilities, all of which enhance the functionality of data tables in web applications.

    How does AG Grid handle large datasets efficiently?

    AG Grid employs a high-performance rendering engine and supports features like row pagination and virtual scrolling, enabling efficient handling and display of large datasets without compromising performance.

    Can AG Grid be customized to fit specific application needs?

    Yes, AG Grid is highly customizable. Developers can define custom column types, cell renderers, and various configurations to tailor the grid's appearance and behavior to specific application requirements.

    Is it possible to use AG Grid with minimal JavaScript in a LiveView application?

    Yes, by leveraging LiveView's server-side rendering and AG Grid's configuration options, developers can implement advanced table features with minimal JavaScript, maintaining a clean separation between Elixir and JavaScript code.

    What are the key features of AG Grid's Community Edition?

    Key features include high-performance rendering, advanced sorting and filtering, dynamic column management (such as pinning and resizing), custom cell rendering, and data export capabilities like CSV export.

    How does AG Grid's integration affect the server-client communication in LiveView?

    Integrating AG Grid with LiveView involves setting up JavaScript hooks that handle client-side interactions, while LiveView manages server-side state and updates. This setup ensures efficient communication and real-time updates between the server and client.

    Are there any prerequisites for integrating AG Grid into a Phoenix LiveView project?

    Before integrating AG Grid, ensure that your Phoenix LiveView project is properly set up. Familiarity with LiveView's hooks and basic JavaScript is beneficial for a smooth integration process.

    Where can I find a working demo of AG Grid integrated with Phoenix LiveView?

    A comprehensive Livebook demo showcasing the integration of AG Grid with Phoenix LiveView is available, providing interactive examples and real-time experimentation opportunities.

    Curiosum Elixir Developer Jakub
    Jakub Lambrych Elixir Developer

    Read more
    on #curiosum blog