datatableselixirphoenix-framework

DataTables.net DOM overridden with Phoenix LiveView DOM render


Title

How to Integrate Phoenix LiveView with DataTables.js Without DOM Conflicts?

Versions

Problem

I’m using Phoenix LiveView to render a DataTable (via live_component) within a static route. The LiveView module is rendered with live_render (:not_mounted_at_router) inside a static .eex template. On initial mount, the DataTable works as expected. However, when handling an event (e.g., filtering rows), LiveView updates the DOM, overwriting the div.datatables_wrapper, which causes DataTables.js to lose track of its state.

The relevant project structure is:

Goal

I want LiveView to handle dynamic updates (e.g., filtering) while keeping DataTables.js functional for its features (sorting, pagination, etc.), without DOM conflicts.

What I’ve Tried

  1. Using phx-update="ignore" on the DataTable

    • Idea: Let DataTables manage its DOM and push data via push_event(socket, "render-datatable-payload", %{}).
    • Issue: Offloads rendering to the client, reducing LiveView’s benefits. I’d prefer a server-driven solution.
  2. Destroying/Reinitializing DataTables

    • Idea: Use push_event to notify the JS hook to destroy the DataTable before LiveView updates the DOM, then reinitialize it.
    • Issue: The updated hook runs after the DOM update, and the event arrives too late, causing timing issues. Performance also concerns me.
  3. Ditching DataTables for a Native Solution

    • Idea: Use a Phoenix-native table library or custom implementation.
    • Issue: DataTables.js is heavily used in this project, and replacing it risks breaking user-expected functionality. Not a preferred option unless necessary.
  4. Hybrid Approach

    • Idea: Let LiveView handle data updates and minimize client-side DOM manipulation.
    • Issue: I’m struggling to make this work smoothly and need guidance.

Questions

Relevant Code

item_live.ex

defmodule FulfillmentCartWeb.ItemLive do
  use FulfillmentCartWeb, :live_view

  def mount(:not_mounted_at_router, _session, socket) do
    {:ok, assign(socket, rows: Items.list_items(), options: options())}
  end

  def render(assigns) do
    ~L"""
    <%= live_component @socket,
          MyAppWeb.Components.DataTable,
          id: "item-table",
          rows: @rows,
          options: @options %>
    """
  end

  defp options do
    %{
      "stateSave" => true,
      "info" => false,
      "pageLength" => 15,
      "dom" => "Bfrtip",
      "buttons" => [%{"extend" => "csv", "text" => "Download CSV"}]
    }
  end
end

data_table.js (LiveView Hook)

const $ = window.jQuery;

const DataTableInit = {
  mounted() {
    this.initDataTable();
  },
  updated() {
    // Issue: Runs after DOM update, too late to destroy/reinit DataTable
  },
  initDataTable() {
    const tableId = `#${this.el.id}`;
    const options = JSON.parse(this.el.dataset.options || '{}');
    $(tableId).DataTable({ ...options });
  }
};

export default DataTableInit;

app.js

let liveSocket = new LiveSocket("/live", Socket, {
  params: {_csrf_token: csrfToken},
  hooks: { DataTableInit }
});

Notes

Any advice or examples would be greatly appreciated!


Solution

  • Using phx-update="ignore" on the DataTable

    • Idea: Let DataTables manage its DOM and push data via push_event(socket, "render-datatable-payload", %{}).

    • Issue: Offloads rendering to the client, reducing LiveView’s benefits. I’d prefer a server-driven solution.

    When LiveView updates the DOM, Datatable still needs to do some work to reflect the DOM changes. Datatable is managing its own DOM, so LiveView should not interfere with it. When you say phx-update="ignore" you are telling LiveView not to update the DOM because you are going to do it yourself in your hook, on the client side.

    The only flow that I can think of that would work is to push the new data using push_event and handle it on the client side.

    // datatable_hook.js
    
    const DataTableInit = {
      mounted() {
        this.initDataTable();
        this.handleEvent("datatable-update", ({ rows }) => {
          this.dataTable.clear();
          this.dataTable.rows.add(rows);
          this.dataTable.draw(false);
        });
      },
      initDataTable() {
        const tableId = `#${this.el.id}`;
        const options = JSON.parse(this.el.dataset.options || '{}');
        this.dataTable = $(tableId).DataTable({ data: [], ...options });
      }
    };
    
    //my_live_view.ex
    
    def handle_event("filter-data", _params, socket) do
      {:noreply, push_event(socket, "datatable-update", %{rows: get_table_rows(/* whatever_filter_you_like */)})}
    end