Interactive Data Tables in Phoenix LiveView: AG Grid Integration Made Simple
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.
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.
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 tablerowData
contains the actual datadefaultColDef
applies settings to all columnsgridOptions
configuration format understood by the AG Gridpush_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):
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:
- Value Formatters - for text-only transformations
- 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:
- Define reusable column types in JavaScript
- 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:
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:
- We need to create DOM elements, not just format text
- 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:
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:
- Accessing Nested Fields: How to display data from nested structures
- JSON Serialization: Ecto structs don't implement the JSON.Encoder protocol by default, so they can't be directly sent over LiveView
- 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:
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:
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.