Creating a Drag-and-Drop File Uploader with React & TypeScript

A while ago, I was working on a project based off the Atomic SPA Starter Kit, which uses TypeScript, React, and GraphQL. One of the features we needed to build was a file upload component that supported dragging and dropping a file from a separate window onto the UI.

Developing the Component

While I was building the component, I ran into a couple of issues. When I was first building out the drag-and-drop functionality, I noticed some strange interactions with the UI. They appeared to happen when different types of drag events were fired in conjunction with the styles applied to the UI, such as padding.

Another mistake I made was not fully utilizing what React had to offer. We wanted the styles to indicate when a file was being dragged over the UI and when a file had been dropped. When I was originally building the component, based off past JavaScript experience in other frameworks, I was using the React component listener hooks for dragenter, dragleave, and drop to add or remove classes from the DOM element as necessary.

When I discussed the setup of the components with Drew, the author of the starter kit post referenced above, he explained to me that there was a better, more “React-y” way to accomplish what I was doing. Instead of manipulating the DOM directly, we can track state within the component to determine whether or not the user is currently dragging. Then we can use that to trigger changes to the UI.

The FileUploader Component

In our system, we had several of these components hooked into a single page which kept track of the dropped files in its state. For the sake of keeping this explanation succinct, I’ll roll that up with the state of the file uploader component itself.

First, we need to define the state that the component will use.


type State = {
  dragging: boolean;
  file: File | null;
}

Because we’re focusing on just the FileUploader component, we’re not going to worry about which props might be passed into it. We’ll still define a props type for convention’s sake, and to demonstrate where you might define those if you need them.

In our application, one of the props we pass into this component is a function that manages when a file is dropped or selected on the FileUploader.


type Props = {}

Now, to the good stuff. The main FileUploader component looks like this:


class FileUploader extends React.Component<Props, State> {
  static counter = 0;
  fileUploaderInput: HTMLElement | null = null;

  constructor(props: Props) {
    super(props);
    this.state = { dragging: false, file: null };
  }

  dragEventCounter = 0;
  dragenterListener = (event: React.DragEvent<HTMLDivElement>) => {
    this.overrideEventDefaults(event);
    this.dragEventCounter++;
    if (event.dataTransfer.items && event.dataTransfer.items[0]) {
      this.setState({ dragging: true });
    } else if (
      event.dataTransfer.types &&
      event.dataTransfer.types[0] === "Files"
    ) {
      // This block handles support for IE - if you're not worried about
      // that, you can omit this
      this.setState({ dragging: true });
    }
  };

  dragleaveListener = (event: React.DragEvent<HTMLDivElement>) => {
    this.overrideEventDefaults(event);
    this.dragEventCounter--;

    if (this.dragEventCounter === 0) {
      this.setState({ dragging: false });
    }
  };

  dropListener = (event: React.DragEvent<HTMLDivElement>) => {
    this.overrideEventDefaults(event);
    this.dragEventCounter = 0;
    this.setState({ dragging: false });

    if (event.dataTransfer.files && event.dataTransfer.files[0]) {
      this.setState({ file: event.dataTransfer.files[0] });
    }
  };

  overrideEventDefaults = (event: Event | React.DragEvent<HTMLDivElement>) => {
    event.preventDefault();
    event.stopPropagation();
  };

  onSelectFileClick = () => {
    this.fileUploaderInput && this.fileUploaderInput.click();
  };

  onFileChanged = (event: React.ChangeEvent<HTMLInputElement>) => {
    if (event.target.files && event.target.files[0]) {
      this.setState({ file: event.target.files[0] });
    }
  };

  componentDidMount() {
    window.addEventListener("dragover", (event: Event) => {
      this.overrideEventDefaults(event);
    });
    window.addEventListener("drop", (event: Event) => {
      this.overrideEventDefaults(event);
    });
  }

  componentWillUnmount() {
    window.removeEventListener("dragover", this.overrideEventDefaults);
    window.removeEventListener("drop", this.overrideEventDefaults);
  }

  render() {
    return (
      <FileUploaderPresentationalComponent
        dragging={this.state.dragging}
        file={this.state.file}
        onSelectFileClick={this.onSelectFileClick}
        onDrag={this.overrideEventDefaults}
        onDragStart={this.overrideEventDefaults}
        onDragEnd={this.overrideEventDefaults}
        onDragOver={this.overrideEventDefaults}
        onDragEnter={this.dragenterListener}
        onDragLeave={this.dragleaveListener}
        onDrop={this.dropListener}
      >
        <input
          ref={el => (this.fileUploaderInput = el)}
          type="file"
          className="file-uploader__input"
          onChange={this.onFileChanged}
        />
      </FileUploaderPresentationalComponent>
    );
  }
}

A bulk of the component is focused on handling the drag and drop events. One thing that might look a bit strange is the dragEventCounter variable on the component class. I found that I needed to track the drag events to correctly trigger the UI while dragging. Without this, I get flickering and other strange behavior.

It’s also important to note that I opted to override the default behavior of dragover and drop on the entire window. The event listeners for those are added and removed in the component lifecycle hooks. I did this so that if a user accidentally drops the file outside of the UI, it won’t open the file in the browser, since that can be a bit annoying.

Presentational Component

In the render function of the FileUploader component, I’m not concerned with all the details of what the UI will look like. Instead, that responsibility is delegated to the FileUploaderPresentationalComponent. Since that component is only concerned about UI and is not responsible for any state, I’ve elected to make it a Stateless Functional Component. That component and its props looks like this:


type PresentationalProps = {
  dragging: boolean;
  file: File | null;
  onSelectFileClick: () => void;
  onDrag: (event: React.DragEvent<HTMLDivElement>) => void;
  onDragStart: (event: React.DragEvent<HTMLDivElement>) => void;
  onDragEnd: (event: React.DragEvent<HTMLDivElement>) => void;
  onDragOver: (event: React.DragEvent<HTMLDivElement>) => void;
  onDragEnter: (event: React.DragEvent<HTMLDivElement>) => void;
  onDragLeave: (event: React.DragEvent<HTMLDivElement>) => void;
  onDrop: (event: React.DragEvent<HTMLDivElement>) => void;
};

export const FileUploaderPresentationalComponent: React.SFC<
  PresentationalProps
> = props => {
  const {
    dragging,
    file,
    onSelectFileClick,
    onDrag,
    onDragStart,
    onDragEnd,
    onDragOver,
    onDragEnter,
    onDragLeave,
    onDrop
  } = props;

  let uploaderClasses = "file-uploader";
  if (dragging) {
    uploaderClasses += " file-uploader--dragging";
  }

  const fileName = file ? file.name : "No File Uploaded!";

  return (
    <div
      className={uploaderClasses}
      onDrag={onDrag}
      onDragStart={onDragStart}
      onDragEnd={onDragEnd}
      onDragOver={onDragOver}
      onDragEnter={onDragEnter}
      onDragLeave={onDragLeave}
      onDrop={onDrop}
    >
      <div className="file-uploader__contents">
        <span className="file-uploader__file-name">{fileName}</span>
        <span>Drag & Drop File</span>
        <span>or</span>
        <span onClick={onSelectFileClick}>
          Select File
        </span>
      </div>
      {props.children}
    </div>
  );
};

This render function does a little more work. It checks whether or not the dragging flag has been set and adjusts the CSS classes accordingly. It also checks whether or not there is currently a file and displays the file name when it is present. I’ve rendered it all out in a relatively simple fashion. Here’s some CSS to get it looking a bit more readable:


.file-uploader {
  width: 300px;
  height: 150px;
  border: 2px solid steelblue;
  border-radius: 8px;
  padding: 10px;
  font-size: 16px;
}

.file-uploader--dragging {
  background-color: lightblue;
}

.file-uploader__contents {
  height: 100%;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  align-items: center;
}

.file-uploader__file-name {
  font-weight: 700;
}

Nothing remarkably fancy, but it will visualize the dragging action and emphasize the file name portion that will change. Hopefully, at this point, you will also be able to interact with your file uploader–either by dragging and dropping files to uploader or by selecting a file through the operating system’s file explorer.

Additional Benefit of a Presentational Component

Separating the presentational portion into its own component certainly helps clean things up and keep each piece shorter and simpler. But another benefit is being able to render out that presentational component in our Storybook stories so we can see what the UI will look like in each of the various states it may have. I definitely prefer this method to making CSS changes and having to manually drag and drop files over the upload to see what it looks like!

I hope this walkthrough helped. Please don’t hesitate to ask any questions about the setup!

Conversation
  • Aashish Tyagi says:

    Thanks , Great tutorial its really helpful and its working fine
    Thanks again

  • Nicholas Murray says:

    Thanks for a great tutorial, I was struggling converting this javascript to typescript and your example code made it so clear how to implement this. Well done!!

    // Prevent default drag behaviors
    [‘dragenter’, ‘dragover’, ‘dragleave’, ‘drop’].forEach(eventName => {
    dropZone.addEventListener(eventName, preventDefaults, false)
    document.body.addEventListener(eventName, preventDefaults, false)
    })

  • Comments are closed.