Logo

Exporting binary data from your React application

This morning I worked on implementing a simple feature for Maffin to let users export their data directly from the website. It's the same as grabbing the saved file from your Google drive but this feature proves useful for users that play with the demo website and then want to export their data.

Implementing this I run into couple of gotchas so I decided it would be nice to share.

๐Ÿ” Research

I had no idea how to implement this so I did some quick research and arrived to this answer in SO. The proposed solution looks like this:

var sampleBytes = new Int8Array(4096);

var saveByteArray = (function () {
    var a = document.createElement("a");
    document.body.appendChild(a);
    a.style = "display: none";
    return function (data, name) {
        var blob = new Blob(data, {type: "octet/stream"}),
            url = window.URL.createObjectURL(blob);
        a.href = url;
        a.download = name;
        a.click();
        window.URL.revokeObjectURL(url);
    };
}());

saveByteArray([sampleBytes], 'example.txt');

There are two key things here:

๐Ÿ”ง Implementation

With this new knowledge, I decided to implement the following component that lets users download their data:

import React from 'react';
import { BiExport } from 'react-icons/bi';
import pako from 'pako';

import { DataSourceContext } from '@/hooks';

export interface ImportButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  className?: string;
}

export default function ImportButton({
  className = 'btn btn-primary',
  ...props
}: ImportButtonProps): JSX.Element {
  const { isLoaded, datasource } = React.useContext(DataSourceContext);
  const ref = React.useRef<HTMLAnchorElement>(null);

  return (
    <>
      // This is the button the user clicks to export the data
      <button
        id="export-button"
        type="button"
        disabled={!isLoaded}
        className={className}
        onClick={() => {
          if (ref.current) {
            const rawBook = datasource?.sqljsManager.exportDatabase() as Uint8Array;
            const blob = new Blob([rawBook], { type: 'application/vnd.sqlite3' });

            ref.current.href = window.URL.createObjectURL(blob);
            ref.current.click();
          }
        }}
        {...props}
      >
        <BiExport className="inline-block align-middle mr-1" />
        <span className="inline-block align-middle">Export</span>
      </button>
   
     // This is the hidden href that actually downloads the data. We click it from the button onClick.
      <a
        className="hidden"
        href="/#"
        ref={ref}
        download="book.sqlite"
      >
        Download
      </a>
    </>
  );
}

See that we create a button and a hidden anchor. In the onClick of the button is where all the interesting logic happens where:

  • We export the database.
  • We create a URL using the createObjectURL and set it as the href attribute of the anchor.
  • We trigger the download by "clicking" the anchor from the button onClick using ref.current.click.

๐Ÿงช Testing

For this post I also want to show the test because I found couple of issues with jest that needed some "research".

First is that createObjectURL is not defined so you need to mock it. I used (window.URL.createObjectURL as jest.Mock).mockReturnValue('blob:url');.

The other one is that navigation is not supported so, when in jest you simulate the click to the button, it will trigger an error. In order to workaround this, I added an even listener to the anchor that prevents the default behavior:

    const hiddenLink = screen.getByRole('link');
    const mockClick = jest.fn(); // So we can prove that it was clicked
    hiddenLink.addEventListener(
      'click',
      (e) => {
        e.preventDefault();
        mockClick();
      },
    );

This is the full test:


  it('exports data from datasource', async () => {
    (window.URL.createObjectURL as jest.Mock).mockReturnValue('blob:url');
    const mockDatasource = {
      sqljsManager: {
        exportDatabase: jest.fn().mockReturnValue(new Uint8Array([22, 33])) as SqljsEntityManager['exportDatabase'],
      },
    } as DataSource;

    render(
      <DataSourceContext.Provider
        value={{
          isLoaded: true,
          datasource: mockDatasource,
        } as DataSourceContextType}
      >
        <ExportButton />
      </DataSourceContext.Provider>,
    );

    // https://github.com/jsdom/jsdom/issues/2112#issuecomment-926601210
    const hiddenLink = screen.getByRole('link');
    const mockClick = jest.fn();
    hiddenLink.addEventListener(
      'click',
      (e) => {
        e.preventDefault();
        mockClick();
      },
    );

    const button = await screen.findByRole('button', { name: 'Export' });
    await userEvent.click(button);

    expect(hiddenLink).toHaveAttribute('href', 'blob:url');
    expect(mockClick).toBeCalled();

    expect(window.URL.createObjectURL).toBeCalledWith(
      new Blob([new Uint8Array([22, 33])], { type: 'application/vnd.sqlite3' }),
    );
  });

By steps, we do the following:

  • Render the element with a DataSourceProvider (which is our typeorm datasource containing the database).
  • Find the Export button and click it.
  • Check that the href has the right url and that it has been clicked.
  • Check that we've created the blob and its object URL with the right data.

๐Ÿ’š Conclusion

I'm quite happy with the result and how simple it has ended up being. As always, you can find the full PR we used to ship these changes here.

That's it! If you enjoyed the post feel free to react to it in the Github tracker (for now).