Welcome

  • Who is this for: Front-end developers
  • What you will learn: Why you can’t request the download of a file with the content-disposition header using XHR, and how to hack around it
  • What you will build: A button triggering a download from a secured endpoint, using XHR and React
  • Prerequisites: React/Typescript knowledge, basic XHR understanding
  • Time needed: 15 minutes

Download CSV

We have recently released a feature allowing users to download the summary of their load tests as a CSV file. My mission was pretty straightforward: display a simple button, labeled “Download CSV”, that would trigger the download of the file when the user clicks on it.

As a developer, you sometimes start working on a task thinking it will be wrapped up rapidly but end up spending a lot more time than you expected, one problem leading to another. Most of the time though, it is worth the pain as you learn a lot on the way. The “Download CSV” button was one of them. 

So if you are interested in front-end development, React/Typescript, or hair-pulling problems, grab a cup of coffee, get comfortable in your seat, and let me tell you about my journey.

First implementation, big disillusion

The first idea my team and I came up with was to implement a link to the CSV file which, when clicked, would open a new tab and initiate the download. However, the issue is that this resource is secured, and we handle authentication through a JWT token, not cookies. This constraint forces us to use our application’s Axios layer, which handles authentication by adding headers to all the requests that require it.

With this in mind, I developed a button with an onClick property that would trigger an Axios GET request to the new endpoint my awesome backend coworker had just developed. This endpoint serves the CSV content in the body response and exposes the Content-disposition header to indicate that the browser needs to trigger a download prompt. It was only a matter of minutes before the button was ready, or so I thought…

Missing Download CSV

It did not work… At all.

The “Download CSV” was here. Upon clicking it, the request was sent to the server, which responded with the data in the body of the response. The Content-disposition header was present as expected. Everything appeared to be in order. However, frustratingly enough, the file download prompt didn’t appear, no matter how many times I attempted to click on it. 😀

 

The problem

Download CSV Problem

After hours of triple-checking the request being sent, the response being received and the headers, I stumbled upon this article.

Aside from being very funny, this article described my exact situation and explained why my download prompt was not triggered. Since the request was done via XHR (in other words, via Javascript/Axios), the browser would delegate the answer to XHR handlers, and ignore the Content-disposition header!

Having identified the problem, my team and I thought about possible solutions:

  • Using a polymorphic component to implement an anchor that would render as a button, and bind the href of this link to an insecure endpoint (which could potentially contain a token as a path parameter). This would bypass the Axios layer of our application and the browser would make the request itself. For technical and security reasons, this solution was not chosen.
  • Adopt the hack provided by Emily in her article.


The hack

Download CSV Solution

The general approach of the hack can be summarized like this:

  • Make the request using XHR to get the CSV data (in our case, via Axios).
  • Build a blob to store the CSV data.
  • Create an invisible anchor in the DOM, and bind its href to the blob’s URL.
  • Use Javascript to virtually click the anchor.

Nevertheless, this method brings new problems:

  • It implies direct DOM manipulation, outside of the React lifecycle, which is very prone to bugs.
  • Neither the blob object nor the anchor is deleted after downloading, which leads to memory leaks.

 

To address these two challenges, we decided to build a custom React hook that would handle these hacky downloads

Let’s dive in together!


Building the hook

First, we defined an interface that represents the file returned by the server.

export interface DownloadableResponse {
  blob: Blob;
  filename: string;
}

Our client layer is responsible for building this object from the server response when we request the CSV data for a simulation.

This is our blobMapper. The code responsible for mapping Axios responses with blob into our DownloadableResponse.

export const blobMapper = (response: AxiosResponse<BlobPart>): DownloadableResponse => ({
  blob: new Blob([response.data]),
  filename: getFilenameFromHeaders(response.headers)
});

The getFilenameFromHeaders is a custom method that extracts the filename from the “content-disposition” header. If you’re interested in it, be my guest:

const CONTENT_DISPOSITION_HEADER = "content-disposition";

export const getFilenameFromHeaders = (headers: RawAxiosResponseHeaders | AxiosResponseHeaders): string => {
  const headerLine = headers[CONTENT_DISPOSITION_HEADER];
  if (!headerLine) {
    throw new Error(`could not find '${CONTENT_DISPOSITION_HEADER}' header`);
  }

  try {
    return headerLine.split("filename=")[1].replace(/"/g, "");
  } catch {
    throw new Error(`could not parse '${CONTENT_DISPOSITION_HEADER}' header`);
  }
};

From there, we can start building the hook:

interface FileDownload {
  download: () => void;
}

export const useDownloadFile = (request: () => Promise<DownloadableResponse>): FileDownload => {
[...]
}

The hook accepts only one argument, a request builder. Then it returns an object that provides a download callback.

 

Creating the anchor

The first thing the hook needs to do is instantiate an anchor element in the DOM, and remove it from the DOM when the component using it is unmounted.

const anchorRef = useRef<HTMLAnchorElement | null>(null);

useEffect(() => {
    const anchor = document.createElement("a");
    anchor.style.display = "none";
    anchorRef.current = anchor;
    document.body.appendChild(anchor);

    return () => {
      document.body.removeChild(anchor);
    };
  }, []);

As you can see:

  • We keep a reference to the anchor (anchorRef) as we need to set its URL and virtually click on it later.
  • We set its CSS display property to “none” as we do not want it to appear anywhere.
  • We provide an empty array of dependencies to useEffect, as we want this code to be run only on the mounting and unmounting of the component. Be careful! Providing no array at all would be a mistake, as the code would be run at every render!


Building the download callback

The download callback is pretty straightforward too:

const download = useCallback(() => {
  request()
    .then(({ blob, filename }) => {
      const url = URL.createObjectURL(blob);
      if (anchorRef.current) {
        anchorRef.current.href = url;
        anchorRef.current.download = filename;
        anchorRef.current.click();
      }
    })
    .catch(() => {
// Handle error here
    });
}, [request]);
  • We execute the request, and we get the blob’s URL by calling URL.createObjectURL from the browser API.
  • We bind the anchor to that URL.
  • We set the download filename property.
  • And we click on it!


Deleting the blob

At this point, our hook is functional. But we still need to address one last thing: we want the blob to be deleted from the browser memory when we unmount the component.

Why not remove it as soon as the download finishes, you may ask? That would be better, indeed, but no browser API provides such a callback. So, unmounting it is!

To do that, we need to invoke URL.revokeObjectURL with the blob’s URL. So, we need to keep track of it using a local state.

const [url, setUrl] = useState("");

const download = useCallback(() => {
  request()
    .then(({ blob, filename }) => {
      const url = URL.createObjectURL(blob);
      setUrl(url); // Keeping the URL in a local state

      if (anchorRef.current) {
        anchorRef.current.href = url;
        anchorRef.current.download = filename;
        anchorRef.current.click();
      }
    })
    .catch(() => {
// Handle error here
    });
}, [request]);

Then, another useEffect handles the blob revocation.

useEffect(() => {
    const toRevoke = url;
    return () => {
      if (toRevoke) {
        URL.revokeObjectURL(toRevoke);
      }
    };
  }, [url]);

Please note that URL.revokeObjectURL does not ensure that the blob is released from memory, as browsers may handle the memory themselves (to prevent download fails if an object is revoked too early, for instance). From my tests, the blob revocation worked great on Chromium-based browsers, but not with Firefox.

 

Wrapping it up and testing!

At this point, our custom hook looks like this:

interface FileDownload {
  download: () => void;
}

export const useDownloadFile = (request: () => Promise<DownloadableResponse>): FileDownload => {
const anchorRef = useRef<HTMLAnchorElement | null>(null);
const [url, setUrl] = useState("");

useEffect(() => {
    const anchor = document.createElement("a");
    anchor.style.display = "none";
    anchorRef.current = anchor;
    document.body.appendChild(anchor);

    return () => {
      document.body.removeChild(anchor);
    };
  }, []);

useEffect(() => {
    const toRevoke = url;
    return () => {
      if (toRevoke) {
        URL.revokeObjectURL(toRevoke);
      }
    };
  }, [url]);

const download = useCallback(() => {
  request()
    .then(({ blob, filename }) => {
      const url = URL.createObjectURL(blob);
setUrl(url);
      if (anchorRef.current) {
        anchorRef.current.href = url;
        anchorRef.current.download = filename;
        anchorRef.current.click();
      }
    })
    .catch(() => {
// Handle error here
    });
}, [request]);

return { download };
}

Using the hook is very simple.

const ExportCsvButton = (): ReactElement => {
  const { download } = useDownloadFile(/* our Axios request builder */);

  return (
    <Button
      icon="download"
      label="Download CSV"
      variant="info"
      onClick={download}
    />
  );
};

And it works like a charm!


Going further

Adding a status

Our hook works great, and we can still go a little further. As it stands, if the user clicks the button multiple times in quick succession, the download will be initiated multiple times. Again, we are not able to determine whether the download is completed or not, but we could at least wait for the server response before allowing clicking again, right?

Plus, we have already developed a custom button component that accepts an isLoading boolean prop and changes the button’s icon to a loader when it’s truthy. It would be great if the hook provided a status property along with the callback, indicating whether the download is ready or if the user has already clicked the button, for example.

So let’s do this!

In the FileDownload object returned by the hook, we add a status property.

type DownloadStatus = "initializing" | "ready" | "pending";

interface FileDownload {
  download: () => void;
  status: DownloadStatus;
}

Let’s also add a local state, set to initializing by default to keep track of the status.

const [status, setStatus] =
useState<DownloadStatus>("initializing");

When the anchor is created in the first effect, we can set the status to ready.

useEffect(() => {
    const anchor = document.createElement("a");
    anchor.style.display = "none";
    anchorRef.current = anchor;
    document.body.appendChild(anchor);

    setStatus("ready"); // <-- here!

    return () => {
      document.body.removeChild(anchor);
    };
  }, []);

As soon as the user clicks the button (i.e., invokes the download callback), we can set the status to pending. When the request is done, whether it succeeded or failed, we set the status back to ready, using the finally callback from the Promises API.

const download = useCallback(() => {
  setStatus("pending"); // <-- the user has clicked, status is "pending"
  request()
    .then(({ blob, filename }) => {
      const url = URL.createObjectURL(blob);
      if (anchorRef.current) {
        anchorRef.current.href = url;
        anchorRef.current.download = filename;
        anchorRef.current.click();
      }
    })
    .catch(() => {
      // Handle error here
    })
    .finally(() => {
        setStatus("ready");  // <-- back to "ready"
    });
}, [request]);

Finally, let’s return the status along with the download callback, and bind it to our custom LoadingButton.

const ExportCsvButton = (): ReactElement => {
  const { download, status } = useDownloadFile(/* our Axios request builder */);

  return (
    <LoadingButton
      icon="download"
      label="Download CSV"
      variant="info"
      disabled={status !== "ready"}
      isLoading={status === "pending"}
      onClick={download}
    />
  );
};

Good! Now, the button is disabled until the download is ready. Besides, while the server responds, the button is disabled and its icon is replaced by a loader! How cool is that?

Typing our blob

Blobs are cool. But you know what’s even cooler? Typed blobs.

The browser API allows us to indicate our blob type when creating them.

Remember our blobMapper from earlier? Let’s tweak it a little more.

export const blobMapper = (response: AxiosResponse<BlobPart>): 
DownloadableResponse => ({
  blob: new Blob([response.data], { type:
 response.headers["content-type"] ?? "application/octet-stream"
 }),
  filename: getFilenameFromHeaders(response.headers)
});

We take advantage of the content-type header to type our blob accordingly. If the header is not present, we at least fall back to octet-stream.

And just like that, we avoid having an “unknown type” blob. 🙂

 

Promises ≠ Observables

Another pitfall I’ve fallen into is that as a former Angular developer, I have worked way more with Observables than Promises. As a result, I didn’t wrap my request in a builder at first, thinking that it would be triggered when the user clicked on the button (as Observables are not triggered until something really subscribes to it).

It looked like this. Please do not reproduce!

/* WRONG ! The response promise was not wrapped in a builder */
export const useDownloadFile = (response: Promise<DownloadableResponse>): FileDownload => {
[...]
const download = useCallback(() => {
    response
      .then(({ blob, filename }) => [...]
}

See? The problem here is that as soon as the component is mounted, the response would require to build the download callback! Consequently, the request to the server is made even if the user hasn’t clicked the button yet. I’m making a data request unnecessarily, without any guarantee of its usefulness.

Wrapping your response into a builder prevents this, as the builder will only be called when the callback is!

/* RIGHT ! The response promise is wrapped in a builder */
export const useDownloadFile = (request: () => Promise<DownloadableResponse>): FileDownload => {
[...]
const download = useCallback(() => {
    request()
      .then(({ blob, filename }) => [...]
}


Final thoughts and takeaways

Even if it’s not perfect, the hook works great.

Still, there’s some direct DOM manipulation with appendChild, removeChild, and element.click(), but we managed to integrate the hacky way of triggering a download via XHR request into the React lifecycle. Plus, the hack is contained inside a single hook, so if we ever find a better way to do this in the future, we only have a single file to edit.

As you can see, something as simple as implementing a button to download a file can imply lots of hidden pitfalls. Let’s sum them up!

  • XHR requests can’t trigger download prompts. Different APIs, different responsibilities.
  • If you are left with a hacky way of doing something, you should still try to make it as clean and contained as possible.
  • You should stick with your framework/library lifecycle.
  • Type your blobs! And clean them when they aren’t needed anymore.
  • Make sure your requests are effectively executed when you need the result, not necessarily when your component is mounted. 😊

I hope you enjoyed reading this as much as I enjoyed writing it. Until then, happy testing everyone!

Related resources

You might also be interested in…