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

Article autor
August 11, 2025
Interactive Data Tables in Phoenix LiveView: AG Grid Integration Made Simple
Elixir Newsletter
Join Elixir newsletter

Subscribe to receive Elixir news to your inbox every two weeks.

Oops! Something went wrong while submitting the form.

Table of contents

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

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

Related posts

Dive deeper into this topic with these related posts

No items found.