DEV Community

Nyoman Abiwinanda
Nyoman Abiwinanda

Posted on • Updated on

Controller-Free Approach for File Export in Phoenix LiveView

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:

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
Enter fullscreen mode Exit fullscreen mode

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.

<button phx-click="export_xlsx" phx-hook="PushFile">
   Export to excel
</button>
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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

// 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,
});
Enter fullscreen mode Exit fullscreen mode

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.

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

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.

Top comments (2)

Collapse
 
tfantina profile image
Travis Fantina

Fantastic post. What are your thoughts on using this for PDF's?
I have a LV that kicks off an Oban Job to generate a PDF, it would never be longer than a page or two (a just ran a test one and it printed out at 74kb).

Collapse
 
abiwinanda profile image
Nyoman Abiwinanda • Edited

Hey, thanks for your question!

I think it doesn't matter whether you export PDF, excel, or any other type of file. What matters is the exported file size because in the download-file handle event (inside the phoenix-live-view.js file) you could see that the full file is transferred to the client as a base64 string that is being put in an href <a> tag. So as long as the file has a reasonably small size to send to the client then it should be good.

Also, regarding the 74kb file size that you mentioned, noticed that the <a> is instantly removed after the file download so we don't have to worry if we accidentally accumulate base64 strings in the client after each download. I personally feel 74kb is still small because I have worked on a liveview page that re-render 3MB of HTML and can still work properly.

Hope this helps :)