Just a nice evening, rooftop view...

Downloading files in Remix with useFetcher hook

Just a nice evening, rooftop view...
September 6, 2024development6 min read

In one of my projects, I was building a feature that allowed users to download a file after a specific interaction, like clicking a button to generate a report. This scenario is quite common in web applications—for instance, exporting table data as a CSV file or downloading a PDF report or invoice. It’s likely that you’ll need to implement file downloads in your app at some point too.

First, let’s take a look at the simplest way to handle file downloads in Remix with Resource Routes.

Resource Routes in Remix

Resource routes in Remix are simple way to send files to the browser in response to a request. While your regular routes are used to return UI Views (and JSON data from loaders), resource routes can be used to return any kind of data, including files like images, PDFs, CSVs, etc or any other type of response.

The route shouldn’t export any default component, just the loader (or action) function that returns a Response object.

Here is a simple example of a resource route that returns a PDF file:

// src/routes/reports.download.ts
export async function loader({ request }) {
  const pdfData = await generatePdf(); // your function that generates the actual PDF data

  return new Response(pdfData, {
    status: 200,
    headers: {
      "Content-Type": "application/pdf",
      "Content-Disposition": "attachment; filename=report.pdf",
    },
  });
}

And then, in your UI components, you can link to the route to trigger the download:

// src/components/DownloadLink.tsx
import { Link } from "@remix-run/react";

export const DownloadLink = () => {
  return (
    <Link to="/reports/download" download reloadDocument>
      Download PDF
    </Link>
  );
};

There is a small caveat with this approach, as Remix docs mention:

There’s a subtle detail to be aware of when linking to resource routes. You need to link to it with <Link reloadDocument> or a plain <a href>. If you link to it with a normal <Link to="pdf"> without reloadDocument, then the resource route will be treated as a UI route. Remix will try to get the data with fetch and render the component.

This works well for simple file downloads, but for cases that require more complex interactions, like displaying a loading indicator, you may want to use a more flexible approach.

Sending file downloads with useFetcher hook

The useFetcher hook is a part of the Remix framework and it allows you to send requests to your own routes from your components. With this approach, we can fetch the file data from a resource route asynchronously and then trigger the download in the browser.

Let’s change our route to return the PDF data as JSON:

// src/routes/reports.download.ts
export async function loader({ request }) {
  const pdfData = await generatePdf(); // your function that generates the actual PDF data

  return json({
    fileData: pdfData.toString("base64"),
    fileName: "report.pdf",
    fileType: "application/pdf",
  });
}

The action will now return the JSON data with the file data & some extra metadata like file name and type. This will be handy in the client code when we want to trigger the download.

Now, let’s create a component that will handle the download. We’ll use the useFetcher hook to fetch the file data and then trigger the download in the browser.

// src/components/DownloadButton.tsx
import { useEffect } from "react";
import { useFetcher } from "@remix-run/react";

type DownloadLinkData = {
  fileData: string;
  fileType: string;
  fileName: string;
};

// Helper function to convert base64 string to Uint8Array
const base64ToUint8Array = (base64: string): Uint8Array => {
  const binaryString = atob(base64);
  const len = binaryString.length;
  const bytes = new Uint8Array(len);

  for (let i = 0; i < len; i++) {
    bytes[i] = binaryString.charCodeAt(i);
  }

  return bytes;
};

export const DownloadButton = () => {
  const fetcher = useFetcher<DownloadLinkData>();

  const handleDownload = () => {
    if (fetcher.state !== "idle") return;
    fetcher.load("/reports/download");
  };

  useEffect(() => {
    // skip effect if the fetcher is not ready (or we don't have the data)
    if (!fetcher.data || fetcher.state !== "idle") {
      return;
    }

    // get the file data from the fetcher
    // we can use type and name to help the browser handle the download
    const { fileData, fileType, fileName } = fetcher.data;

    // dynamically create a blob with the file data
    const byteData = base64ToUint8Array(fileData);
    const blob = new Blob([byteData], { type: fileType });

    // create a URL for the blob and trigger the download link
    const url = window.URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = fileName;
    a.click();
  }, [fetcher.state, fetcher.data]);

  return (
    <button onClick={handleDownload}>
      {fetcher.state !== "idle" ? "Generating report..." : "Generate report"}
    </button>
  );
};

Using Blob and URL.createObjectURL, we can create a temporary URL for the file data and start the download directly in the browser. The main advantage of this approach is that we can handle the download process in a more flexible way, for example by showing a loading indicator while the file is being generated on the server.

We also had to add a helper function base64ToUint8Array to convert the base64 string to a Uint8Array that can be used to create a Blob. This is necessary if you want to send binary data (like PDFs) from the server to the client. If you deal with text data, you can skip this step and pass the data directly to the Blob constructor.

This is obviously a simplified example, but it should give you a good starting point for handling file downloads in Remix. You can easily extract this logic into a custom hook or a separate component to reuse it across your application.

For example, a custom hook might look like this:

export function useDownloadFetcher() {
  const fetcher = useFetcher();

  useEffect(() => {
    if (!fetcher.data || fetcher.state !== "idle") {
      return;
    }

    // dynamically create download link
    const link = window.URL.createObjectURL(new Blob([fetcher.data.fileData], {
      type: fetcher.data.fileType,
    }););

    // create a temporary anchor element
    const a = document.createElement("a");
    a.href = link;
    a.download = fetcher.data.fileName;
    a.click();
  }, [fetcher.data, fetcher.state]);

  return fetcher;
}

In this way, you can easily reuse the download logic in multiple components, while keeping the code clean and maintainable, with consistent and familiar useFetcher API.

Hope this little article will help you to deal with file downloads in your Remix app. For full example code, you can check out the GitHub repository.

If you have any questions or suggestions, don’t hesitate to get in touch with me and share your thoughts.

Happy coding!

You may also like the following content