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.
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:
DropBox
is the container where files can be droppedHiddenInput
must be rendered, but it is invisible to the userdrag
is true
when a file is being dragged over the boxonClick
allows the user to click to select a fileThe 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.
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,
// ...
}
}
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])
}
}
// ...
}
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:
onDragEnter
onDragLeave
onDrop
// 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.
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.
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}
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. */
}
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())
}
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!