File Drop with React Hooks
Sun 08 April 2018, by Matthew Segal
Category: React

A few days ago my boss asked me to make a page where users could upload files. The wireframe looked nice - there was a little box that said "Drop files to upload" where users would ostensibly be dropping their files. Unfortunately there isn't a standard HTML5 widget for this called <dropbox>.

This is one of those frontend features that seem like a given to most end-users and non-technical colleagues ("Should be easy right?"), but it's actually really fiddly. As far as I can tell you have to roll your own widget or use a library. React Dropzone comes highly recommended by Google, but I was hesitant to add another dependency to my project if I could build my own in an hour or two and conifgure it just the way I like it.

I want to share my implementation to help you save 90 minutes of beating your head against your desk. You can find all the example code for this post on GitHub and view it running on GitHub pages. You might want to just check that out directly if you're in a hurry.

React Hooks API

Here's the API for the file drop hook I built:

// form.js
import { useFileDrop } from './hooks'

export const UploadForm = ({ onUpload }) => {
  const { DropBox, HiddenInput, onClick, drag } = useFileDrop(onUpload)
  return (
    <div>
      <HiddenInput />
      <DropBox>
        <div>Drop files to upload.</div>
      </DropBox>
      <button onClick={onClick}>
        Upload file
      </button>
    </div>
  )
}

The hook useFileDrop supplies four objects:

The hook accepts some input function onUpload, which takes a File object and then does something with it, like upload the file to a server via HTTP.

I decided to use hooks, rather than a class-based component for this implementation because I wanted to be able to re-use this feature elsewhere in the app. This is my first attempt at building something with hooks and I'm pretty impressed so far.

Now we have the API, let's start to implement it.

The hidden input

You need to render an <input> somewhere in the DOM to get the browser to open the file selection window. Unfortunately this element looks like crap and is hard to style and my boss didn't include one in his pretty wireframe.

We get around this eyesore by rendering the element to the DOM, but hiding it from the user. We can refer to it using React's Ref API.

// hooks.js
import React, { useRef } from 'react'

export const useFileDrop = onUpload => {

  const ref = useRef(null)

  // Our invisible file input field
  const HiddenInput = () => (
    <input
      type="file"
      ref={ref}
      onChange={onChange}
      style={{ display: 'none' }}
    />
  )

  // Allow users to click on the hidden input.
  const onClick = () => {
    ref.current.click()
  }

  // Handle file selection
  const onChange = e => {
    // todo
  }

  // ...
  return {
    HiddenInput,
    onClick,
    // ...
  }
}

Handling file selection

Implementing onChange is pretty straightforward if you know what to expect. Unless you add the multiple attribute to your input element, the user can only upload one file at a time. Nevertheless, the browser will always give you a FileList.

// hooks.js
import React from 'react'

export const useFileDrop = onUpload => {
  // ...

  // Handle file selection
  const onChange = e => {
    const files = e.target.files
    if (files.length > 0) {
      onUpload(files[0])
    }
  }

  // ...
}

The actual drag and drop bit

At the bare minimum, we need to handle the event where the user drops the file. I also wanted to style the box when the user was dragging the file over it, so I added the drag variable to keep track of that, so we have three sythentic events to handle:

// hooks.js
import React, { useState } from 'react'

export const useFileDrop = onUpload => {
  // ...

  // Use state to keep track of whether we're dragging
  const [drag, setDrag] = useState(false)

  // Handle the user dragging the file in and out.
  const onDragOver = isOver => e => {
    e.preventDefault()
    setDrag(isOver)
  }

  // Handle file drop
  const onDrop = e => {
    e.stopPropagation()
    e.preventDefault()
    const files = e.dataTransfer.files
    if (files && files.length > 0) {
      onUpload(files[0])
    }
    setDrag(false)
  }

  // Container where file can be dropped.
  const DropBox = props => (
    <div
      onDragEnter={onDragOver(true)}
      onDragLeave={onDragOver(false)}
      onDrop={onDrop}
      {...props}
    >
      {!drag && props.children}
    </div>
  )

  return {
    // ...
    DropBox,
    drag,
  }
}

...and that's basically it. Put the snippets above together (eg. here) and you have a hook that you can use to make a file drop component. Below I go into more detail about some of the quirks of the code.

Preventing default behaviour

When I first wrote this code I sprinked e.stopPropagation() and e.preventDefault() into every event handler like a sexy Instagram chef, but I felt bad sharing it with the world and dug a little deeper.

The default behaviour when you drop a file into the browser is for the browser to render that file. We need to block this action using preventDefault in onDrop, which makes sense. Weirdly, you also need to put it in onDragOver as well, in Chrome at least, or the browser just renders the file you dropped.

You also need stopPropagation in onDrop to prevent the file drop event from bubbling up to its parents.

The problem with children

There's this weird problem with the onDragLeave event where it gets triggered if you scroll over a child element. It sucks. There are some crazy StackOverflow answers which you can use to punch your way out of this mess, but I decided to go around the problem and just not render any children when drag was active, hence this line inside of DropBox:

{!drag && props.children}

Styling for drag

If you hide the children when the file is dragged over, you can still show something inside the box. Below you can add a drag specific class to the DropBox:

// form.js
import { useFileDrop } from './hooks'

export const UploadForm = ({ onUpload }) => {
  const { DropBox, HiddenInput, onClick, drag } = useFileDrop(onUpload)
  return (
    <div>
      <HiddenInput />
      <DropBox className={`upload-box ${drag ? 'drag' : ''}`}>
        <div>Drop files to upload.</div>
      </DropBox>
      <button onClick={onClick}>
        Upload file
      </button>
    </div>
  )
}

and then you can use a :before pseudo-element to show some text:

.upload-box {
    /* some styles */
}

.upload-box.drag {
  position: relative;
  /* more styles */
}

.upload-box.drag:before {
  position: absolute;
  content: 'Drop file to upload';
  top: 50%;
  left: 0;
  right: 0;
  width: 100%;
  text-align: center;
  transform: translateY(-50%);
  /* colors, etc. */
}

Uploading the file.

This is a little out of scope, but it's weird enough that I want to share it. Assuming you have an API which accepts form data and returns JSON, you can upload a File object as follows:

const upload = file => {
  const form  = new FormData()
  form.append('file', file)
  return fetch('/upload/', {
    method: 'POST',
    body: form,
  })
  .then(r => r.json())
}

Conclusion

Hopefully you now have the knowledge and context to build your own file drop hook, which you can customise and re-use across your app. Have fun!

I'm a a web developer from Melbourne, Australia. I spend my days swinging angle brackets against the coal face, while working on Blog Reader in my spare time.