Controller-Free Approach for File Export in Phoenix LiveView
Tags: elixir, phoenix, liveview
Published: 2023-07-04 21:46:31

In a recent project involving LiveView, I was tasked with building a page to display an accounting report. Alongside this requirement, it was important to enable users to export the report as an Excel file. While the common approach involves creating a regular Phoenix controller with an API to generate the Excel file, I sought to explore a more efficient and seamless solution. In this post, I will share an alternative method that allows us to export or generate an excel (or any other) file directly from the LiveView itself, eliminating the need for a separate controller.

## The Traditional Approach

Traditionally, the export feature is implemented by creating a Phoenix controller responsible for building the Excel file and sending it as a response. Here's an example of how it's typically done:

```elixir
defmodule SomeController do
  use AppName, :controller

  # This function is used to generate and export the report Excel file
  def export(conn, params) do
    # Fetch the report data from the DB...
    report_data = fetch_report_data_from_db(params)

    # ...and then build the excel file
    raw_content = generate_excel_file(report_data)

    conn
    |> put_resp_content_type("text/xlsx")
    |> put_resp_header("content-disposition", "attachment; filename=report.xlsx")
    |> text(raw_content)
  end
end
```

While this method is widely used, there is one thing that bothers me. Since the controller independently fetches the report data from the database and generates the Excel file, it duplicates the data retrieval process already performed in the LiveView page. This redundancy raises a question: Can we export the report data to an Excel file without relying on a Phoenix controller? Is it possible to construct the export file directly within the LiveView, reusing the report data stored in the LiveView socket?

## An Alternative Solution

To address this, my co-workers and I tried to devised an approach that enables exporting and downloading files using Phoenix LiveView alone. In our LiveView process, which handles report rendering and houses the download report button, we made a modification. Instead of making an HTTP request to a Phoenix controller, we altered the download button to trigger a `phx-click` event named `export_xlsx` and we also add a `phx-hook` attribute set to `PushFile` to ensure that it triggers the required hook callback in the clients as you will see later.

```html
<button phx-click="export_xlsx" phx-hook="PushFile">
   Export to excel
</button>
```

```elixir
def handle_event("export_xlsx", _, socket) do
   # Reusing the report data fetched during the LiveView access
   report_data = socket.assigns.report_data

   # Building the Excel file
   raw_content = generate_excel_file(report_data)

   {:noreply, push_file(socket, raw_content, "report.xlsx")}
end

defp push_file(socket, binary, filename) do
   prefix = "data:#{MIME.from_path(filename)};base64,"

   Phoenix.LiveView.push_event(socket, "download-file", %{
      base64_data: prefix <> Base.encode64(binary),
      filename: filename
   })
end
```

By constructing the Excel file within the LiveView process, we can conveniently utilize the pre-existing report data stored in the LiveView socket's assigns, which is fetched during the process mount.

What happens after that is where we spent the most time with. We take raw data of the excel file and then send it to client via liveview js (hook) event called `download-file`. This is done by the function `push_file/3` which is just a wrapper we build around the function `Phoenix.LiveView.push_event/3`. In this case we send the file raw data in the form of base64 string.

Once we sent the file raw data to client, the client has to handle the event and do something with it. To handle such event, we define a hook inside the `phoenix-live-view.js` file as follows

```js
// inside phoenix-live-view.js
const Hooks = {};

// Create a new hook that handle the "download-file" event
Hooks.PushFile = {
  mounted() {
    this.handleEvent("download-file", function (event) {
      var element = document.createElement('a');
      element.setAttribute('href', event.base64_data);
      element.setAttribute('download', event.filename);
      element.click();
      element.remove();
    });
  }
}

let liveSocket = new LiveSocket("/live", Socket, {
  hooks: Hooks,
});
```

essentially what the hook does is to create an HTML link tag, click the tag (to download the file), and then removes it. The created link tag looks something like this.

```html
<!-- The excel file is stored using data URL -->
<a href="data:text/xlsx;base64,{base64_data}" download="filename.xlsx"></a>
```

Once the hooks is in place, we were able to download or export the report into an excel file without the need of Phoenix controller 🎉

## Pros & Cons

By eliminating the use of a controller and integrating the Excel file creation directly within the liveview process, we could reduce the download time equivalent to the time it takes to load the report (from the database) in the liveview.

While the impact of this improvement may not be substantial in certain cases, it becomes more noticeable when the report page takes some time to load. Once the report is loaded, users no longer have to wait an additional amount of time to download the report file. This enhancement contributes to a more enjoyable user experience, lending a sense of responsiveness to the download feature.

However, it's important to consider that removing the controller and relying solely on liveview introduces a few drawbacks. Two immediate concerns come to mind:

- Various browsers may impose restrictions on the size of files transmitted using data URLs. Consequently, if the file size exceeds these limitations, the data URL approach may not function as intended.

- In line with the previous drawback, as file sizes increase, transmitting the file using data URLs could consume additional server CPU resources due to the base64 processing involved.

Both the controller-based approach and the approach mentioned in this post have their merits. If download time and responsiveness are not critical factors, the controller approach may be preferable. Ultimately, the choice of approach is up to you, depending on the specific requirements and priorities of your project.