Creating Flexible and Reusable React File Uploaders

The Event Creation team at Eventbrite needed a React based image uploader that would provide flexibility while presenting a straightforward user interface. The image uploader components ought to work in a variety of scenarios as reusable parts that could be composed differently as needs arose. Read on to see how we solved this problem.

What’s a File Uploader?

In the past, if you wanted to get a file from your web users, you had to use a “file” input type. This approach was limited in many ways, most prominently in that it’s an input: data is only transmitted when you submit the form, so users don’t have an opportunity to see feedback before or during upload.

With that in mind, the React uploaders we will be talking about aren’t form inputs; they’re “immediate transport tools.” The user chooses a file, the uploader transports it to a remote server, then receives a response with some unique identifier. That identifier is then immediately associated with a database record or put into a hidden form field.

This new strategy provides tremendous flexibility over traditional upload processes. For example, decoupling file transportation from form submissions enable us to upload directly to third-party storage (like Amazon S3) without sending the files through our servers.

The tradeoff for this flexibility is complexity; drag-and-drop file uploaders are complex beasts. We also needed our React uploader to be straightforward and usable. Finding a path to provide both flexibility and ease-of-use was no easy task.

Identifying Responsibilities

Establishing the responsibilities of an uploader seems easy… it uploads, right? Well sure, but there are a lot of other things involved to make that happen:

  • It must have a drop zone that changes based on user interaction. If the user drags a file over it, it should indicate this change in state.
  • What if our users can’t drag files? Maybe they have accessibility considerations or maybe they’re trying to upload from their phone. Either way, our uploader must show a file chooser when the user clicks or taps.
  • It must validate the chosen file to ensure it’s an acceptable type and size.
  • Once a file is picked, it should show a preview of that file while it uploads.
  • It should give meaningful feedback to the user as it’s uploading, like a progress bar or a loading graphic that communicates that something is happening.
  • Also, what if it fails? It must show a meaningful error to the user so they know to try again (or give up).
  • Oh, and it actually has to upload the file.

These responsibilities are just a short list, but you get the idea, they can get complicated very quickly. Moreover, while uploading images is our primary use case, there could be a variety of needs for file uploading. If you’ve gone to all the trouble to figure out the drag / drop / highlight / validate / transport / success / failure behavior, why write it all again when you suddenly need to upload CSVs for that one report?

So, how can we structure our React Image Uploader to get maximum flexibility and reusability?

Separation of Concerns

In the following diagram you can see an overview of our intended approach. Don’t worry if it seems complicated -we’ll dig into each of these components below to see details about their purpose and role.

React Architecture illustration showing overview of several components that will be enumerated below.

Our React based component library shouldn’t have to know how our APIs work. Keeping this logic separate has the added benefit of reusability; different products aren’t locked into a single API or even a single style of API. Instead, they can reuse as much or as little as they need.

Even within presentational components, there’s opportunity to separate function from presentation. So we took our list of responsibilities and created a stack of components, ranging from most general at the bottom to most specific at the top.

Foundational Components

Recap of the architecture illustration from above with "Foundational Components" highlighted.

UploaderDropzone

This component is the heart of the uploader UI, where action begins. It handles drag/drop events as well as click-to-browse. It has no state itself, only knowing how to normalize and react (see what I did there?) to certain user actions. It accepts callbacks as props so it can tell its implementer when things happen.

Illustration showing a clear pane representing UploaderDropzone over a simple layout in React

It listens for files to be dragged over it, then invokes a callback. Similarly when a file is chosen, either through drag/drop or click-to-browse, it invokes another callback with the JS file object.

It has one of those inputs of type “file” that I mentioned earlier hidden inside for users who can’t (or prefer not to) drag files. This functionality is important, and by abstracting it here, components that use the dropzone don’t have to think about how the file was chosen.

The following is an example of UploaderDropzone using React:

<UploaderDropzone
    onDragEvent={handleDragEvent}
    onFileReceived={handleFileReceived}
>
    Drag a file here!
    <Icon type="upload" />
</UploaderDropzone>

UploaderDropzone has very little opinion about how it looks, and so has only minimal styling. For example, some browsers treat drag events differently when they occur on deep descendants of the target node. To address this problem the dropzone uses a single transparent div to cover all its descendants. This provides the needed experience for users that drag/drop, but also maintains accessibility for screen readers and other assistive technologies.

UploaderLayoutManager

The UploaderLayoutManager component handles most state transitions and knows which layout should be displayed for each step of the process, while accepting other React Components as props for each step.
Illustration showing a block representing the UploaderLayoutManager and several smaller blocks representing layouts being flipped/shuffled

This allows implementers to think about each step as a separate visual idea without the concern of how and when each transition happens. Supporters of this React component only have to think about which layout should be visible at a given time based on state, not how files are populated or how the layout should look.

Here is a list of steps that can be provided to the LayoutManager as props:

  • Steps managed by LayoutManager:
    • Unpopulated – an empty dropzone with a call-to-action (“Upload a great image!”)
    • File dragged over window but not over dropzone (“Drop that file here!”)
    • File dragged over dropzone (“Drop it now!”)
    • File upload in progress (“Hang on, I’m sending it…”)
  • Step managed by component that implements LayoutManager:
    • File has uploaded and is populated. For our image uploader, this is a preview of the image with a “Remove” button.

The LayoutManager itself has little or no styles, and only displays visuals that have been passed as props. It’s responsible for maintaining which step in the process the user has reached and displaying some content for that step.

The only layout step that’s externally managed is “Preview” (whether the Uploader has an image populated). This is because the implementing component needs to define the state in which the uploader starts. For example, if the user has previously uploaded an image, we want to show that image when they return to the page.

Example of LayoutManager use:

<UploaderLayoutManager
    dropzoneElement={<DropzoneLayout />}
    windowDragDropzoneElement={<WindowDragDropzoneLayout />}
    dragDropzoneElement={<DragDropzoneLayout />}
    loadingElement={<LoadingLayout />}
    previewElement={<PreviewLayout file={file} />}

    showPreview={!!file}

    onReceiveFile={handleReceiveFile}
/>

Resource-Specific Components

Recap of the React architecture illustration from above with "General Components" highlighted.

ImageUploader

The ImageUploader component is geared almost entirely toward presentation; defining the look and feel of each step and passing them as props into an UploadLayoutManager. This is also a great place to do validation (file type, file size, etc).

Supporters of this tool can focus almost entirely on the visual look of the uploader. This component maintains very little logic since state transitions are handled by the UploaderLayoutManager. We can change the visuals fluidly with very little concern about damaging the function of the uploader.

Example ImageUploader:

const DropzoneLayout = () => (
    <p>Drag a file here or click to browse</p>
);
const DragDropzoneLayout = () => (
    <p>Drop file now!</p>
);
const LoadingLayout = () => (
    <p>Please wait, loading...</p>
);
const PreviewLayout = ({file, onRemove}) => (
    <div>
        <p>Name: {file.name}</p>
        <Button onClick={onRemove}>Remove file</Button>
    </div>
);
class ImageUploader extends React.Component {
    state = {file: undefined};

    _handleRemove = () => this.setState({file: undefined});

    _handleReceiveFile = (file) => {
        this.setState({file});

        return new Promise((resolve, reject) => {
            // upload the file!
        })
        .catch(() => this.setState({file: undefined}))
    }

    render() {
        let {file} = this.state;
        let preview;

        if (file) {
            preview = (
                <PreviewLayout file={file} onRemove={this._handleRemove} />
            );
        }

        return (
            <UploaderLayoutManager
                dropzoneElement={<DropzoneLayout />}
                dragDropzoneElement={<DragDropzoneLayout />}
                loadingElement={<LoadingLayout />}
                previewElement={preview}
                showPreview={!!file}
                onReceiveFile={this._handleReceiveFile}
            />
        );
    }
};

Application-Specific Layer

Recap of the React architecture illustration from above with "Application-specific layer" highlighted.

The example above has one prominent aspect that isn’t about presentation:  the file transport that happens in _handleReceiveFile. We want this ImageUploader component to live in our component library and be decoupled from API specific behavior, so we need to remove that. Thankfully, it’s as simple as accepting a function via props that returns a promise that resolves when upload is complete.

_handleReceiveFile(file) {
    // could do file validation here before transport. If file fails validation, return a rejected promise.
    let {uploadImage} = this.props;

    this.setState({file});

    return uploadImage(file)
        .catch(() => this.setState({file: undefined}))
}

With this small change, this same image uploader can be used for a variety of applications. One part of your application can upload images directly to a third party (like Amazon S3), while another can upload to a local server for a totally different purpose and handling, but using the same visual presentation.

And now because all that complexity is compartmentalized into each component, the ImageUploader has a very clean implementation:

<ImageUploader uploadImage={S3ImageUploadApi} />

With this foundation applications can use this same ImageUploader in a variety of ways. We’ve provided the flexibility we want while keeping the API clean and simple. New wrappers can be built upon UploadLayoutManager to handle other file types or new layouts.

In Closing

Imagine image uploaders which were purpose-built for each scenario, but contain only a few simple components made of presentational markup.  They can each use the same upload functionality if it makes sense, but with a totally different presentation. Or flip that idea around, using the same uploader visuals but with totally different API interfaces.

In what other ways would you use these foundational components? What other uploaders would you build? The sky’s the limit if you take the time to build reusable components.

Leave a Reply

Your email address will not be published. Required fields are marked *