In this post, we'll build an advanced file picker from the ground up, complete with drag and drop support.

The file picker will have the following features:

  • The ability to choose a single file or a group of files
  • Drag and drop functionality
  • The ability to show the selected files and the option to remove them
  • A progress bar to show upload progress

To play around with the finished code, check out the sandbox here.

Getting started

Let’s get started by scaffolding a basic React project using create-react-app.

 
npx create-react-app file-picker

Then, go to the newly created directory, install the requisite packages, and start the project:

 
cd ./file-picker
npm install
npm run start

This should finish installing all the packages that React needs and automatically start the development server at http://localhost:3000.

We'll need a few more libraries, so let's get those installed as well.

  • nanoid : This small library is used for generating globally unique identifiers. The unique ids are used in the list view for displaying the selected files.
  • classNames : A simple utility for joining classnames together.
  • axios : Promise based HTTP Client for the Browser and Node.  We are choosing to use axios instead of plain fetch to take advantage of onUploadProgress , a special method property of Axios that gets called with a progress event. We will use this method to update the overall progress of the upload.

npm install classnames axios nanoid

Styling

We will use CSS Modules for styling. A CSS module is a CSS file that is scoped locally by default for all class and animation names. Since we used create-react-app to scaffold the project, no extra installation or configuration is needed for CSS modules to work.

User interface

Let's look at how the component will look when finished and understand the different elements that make it up before we start building it.

  • Dropzone: We can drop single or multiple files in this area. The Dropzone also responds to mouse clicks by displaying a file selection dialog.
  • Files List: A simple list view that shows all of the selected files and allows us to delete them.
  • Progress Bar: Shows the upload operation's overall progress.
  • Upload Files button: Starts the upload process.

Folder structure

Create a components folder under the src folder where we’ll put our jsx and css module files.

 
cd src/
mkdir components
cd components/

Here are all of the components and their associated CSS modules that we’ll create in this folder:.

Components and associated CSS modules for our folder

Dropzone

Let’s get started by first creating the Dropzone component, which is responsible for allowing our users to select files through a dialog window or by drag and drop.

The information about the chosen files is sent back to the root component, which is then in charge of both showing the file list view and managing the upload process.

To begin, we'll create a Banner component, which will serve as the primary user interface for selecting files to upload. The banner will have two uses: dragging and dropping files and choosing files from the file dialog.

Note that we are importing the dropzone css module as styles. This scopes the associated classnames to the css module.

// drop-zone.jsx

import React from "react";
import styles from "./drop-zone.module.css";

const Banner = ({ onClick, onDrop }) => {
  const handleDragOver = (ev) => {
    ev.preventDefault();
    ev.stopPropagation();
    ev.dataTransfer.dropEffect = "copy";
  };

  const handleDrop = (ev) => {
    ev.preventDefault();
    ev.stopPropagation();
    onDrop(ev.dataTransfer.files);
  };



  return (
    <div
      className={styles.banner}
      onClick={onClick}
      onDragOver={handleDragOver}
      onDrop={handleDrop}
    >files       <span className={styles.banner_text}>Click to Add files </span>
      <span className={styles.banner_text}>Or</span>
      <span className={styles.banner_text}>Drag and Drop files here</span>
    </div>
  );
};

The Banner component accepts two props for handling the click and drag and drop events on the Banner: functions for onClick and onDrop.

In the handleDragOver method, we call e.preventDefault() to prevent the default action for a dragover event, which is to set the dataTransfer.dropEffect property to “none”, which prevents the drop event from firing. Instead, we want to show a copy icon when a file is dragged into the banner. To do this, we use a native JS object, `dataTransfer`, which holds the data that is being dragged during a drag and drop.

const handleDragOver = (ev) => {
    ev.preventDefault();
    ev.stopPropagation();
    ev.dataTransfer.dropEffect = "copy";
  };

On the other hand, the handleDrop event just calls the onDrop function and passes the selected files to it.

const handleDrop = (ev) => {
  ev.preventDefault();
  ev.stopPropagation();
  onDrop(ev.dataTransfer.files);
};

Now that the Banner component is ready, let's use it in the Dropzone component we're going to build.

Note that since we are creating a "styled" file picker, we need to link clicking on the banner to actually clicking the file input button under. To do this, we use a ref.

// drop-zone.jsx

import styles from "./drop-zone.module.css";

const DropZone = ({ onChange, accept = ["*"] }) => {
  const inputRef = React.useRef(null);

  const handleClick = () => {
    inputRef.current.click();
  };

  const handleChange = (ev) => {
    onChange(ev.target.files);
  };

  const handleDrop = (files) => {
    onChange(files);
  };

  return (
    <div className={styles.wrapper}>
      <Banner onClick={handleClick} onDrop={handleDrop} />
      <input
        type="file"
        aria-label="add files"
        className={styles.input}
        ref={inputRef}
        multiple="multiple"
        onChange={handleChange}
        accept={accept.join(",")}
      />
    </div>
  );
};

export { DropZone };

The dropdown component has an input of type file defined.

<input
  type="file"
  className={styles.input}
  ref={inputRef}
  multiple
  onChange={handleChange}
  accept={accept.join(",")}
/>

Whenever the Banner component is clicked, we click the input, which in turn opens the File dialog for us. Let's go over the other attributes we have defined.

type: Defines the type of input and, in this case, the file.

ref: Used for manually triggering a click event on the input file.

multiple: Allows multiple files to be selected.

accept: The type of file accepted in this case is mapped to the accept prop we have defined for the component.

Finally, let's add the styles for the Dropzone component. This makes the dropzone component look a little more sophisticated and like the classical file selector, with a shadow card as background and a dashed border.

.wrapper {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  width: 100%;
}

.banner_text {
  font-size: 1.5rem;
  color: #ccc;
  display: block;
  margin: 0.5rem 0;
}

.banner {
  background-color: #fafafa;
  width: 100%;
  border: 4px dashed #ccc;
  height: 200px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  margin-bottom: 1rem;
  cursor: pointer;
}

.input {
  width: 100%;
  height: 100%;
  display: none;
}

If all goes well, we should be able to see this view.

Files list

The Files list view displays the files that have been selected and includes an x button that removes the file from the list / uploader when clicked.

Before we begin building the Files list component, let’s first create a few icons for this component. We’ll need one for a check (“successfully uploaded”) and one for an x (“the clear button”).

Create two files in `file-picker/src`, check.js and clear.js.These files will hold the svg icons as React components.

// file-picker/src/check.jsx 

import * as React from "react";
 
const SvgComponent = () => (
 <svg
   xmlns="http://www.w3.org/2000/svg"
   viewBox="0 0 24 24"
   fill="none"
   stroke="currentColor"
   strokeWidth={2}
   strokeLinecap="round"
   strokeLinejoin="round"
 >
   <path d="M20 6 9 17l-5-5" />
 </svg>
);
 
export default SvgComponent;
``
// file-picker/src/clear.jsx 

import * as React from "react";
 
const SvgComponent = () => (
 <svg
   xmlns="http://www.w3.org/2000/svg"
   viewBox="0 0 24 24"
   fill="none"
   stroke="currentColor"
   strokeWidth={2}
   strokeLinecap="round"
   strokeLinejoin="round"
 >
   <path d="M18 6 6 18M6 6l12 12" />
 </svg>
);
 
export default SvgComponent;

Alternatively, we can use an icon library like FontAwesome.

With our icons defined, we can now use the following import to add the icon to any another component:

import CheckIcon from "../check";

With that, let’s get started on our files list component.


The Files List component takes 3 properties.

  • files: A collection of file objects. Each file object contains the file itself, along with the id.
  • uploadComplete: Used to display a check icon next to every file item once the upload is complete. This will be set to true by the parent component once the upload process is complete.
  • onClear: Function executed when a file is removed via the clear button.

Now let's add the styles for the FilesList component.

// files-list.jsx

import React, { useCallback } from "react";
/** icons **/
import CheckIcon from "../check";
import ClearIcon from "../clear";
/** styles **/
import styles from "./files-list.module.css";

const FilesListItem = ({ name, id, onClear, uploadComplete }) => {
  const handleClear = useCallback(() => {
    onClear(id);
  }, []);

  return (
    <li className={styles.files_list_item}>
      <span className={styles.files_list_item_name}>{name}</span>
      {!uploadComplete ? (
        <span
          className={styles.files_list_item_clear}
          role="button"
          aria-label="remove file"
          onClick={handleClear}
        >
          <ClearIcon />
        </span>
      ) : (
        <span role="img" className={styles.file_list_item_check}>
          <CheckIcon />
        </span>
      )}
    </li>
  );
};

const FilesList = ({ files, onClear, uploadComplete }) => {
  return (
    <ul className={styles.files_list}>
      {files.map(({ file, id }) => (
        <FilesListItem
          name={file.name}
          key={id}
          id={id}
          onClear={onClear}
          uploadComplete={uploadComplete}
        />
      ))}
    </ul>
  );
};

export { FilesList };

This code renders a list like below (once we upload a few files).

Now that we have all the parts we need, we will build the main root component (File Picker), which will hold both the Dropzone and the Files List.

File Picker

The File Picker component, which includes both the Dropzone and the Files List component, is in charge of the following:

  • Passing the selected files from the Dropzone component to the Files List Component.
  • Preparing the data for upload.
  • Executing the upload.
  • Managing the state of the upload progress in percentages. This information will be used by the progress bar.
  • Informing the list view when the upload operation is complete.
  • Removing files from the list view when the clear operation is invoked from the list view.

The component accepts two properties.

  • uploadURL - The URL to which the files will be uploaded
  • accept - An array of strings specifying the criteria for file selection. Please refer to this link for all acceptable strings. For example, ["image/png," "image/jpeg"]

Let’s start with a few states.

First off, we need to store the files that are selected via the Dropzone component.

 const [files, setFiles] = useState([]);

Second, a state to indicate the start of the upload process.

const [uploadStarted, setUploadStarted] = useState(false);

Finally, a state that indicates the file(s) upload progress.

const [progress, setProgress] = useState(0);

Let's set up a handler for saving the files selected through the Dropzone.

import { nanoid } from "nanoid";
 
 const handleOnChange = useCallback((files) => {
    let filesArray = Array.from(files);

    filesArray = filesArray.map((file) => ({
      id: nanoid(),
      file,
    }));

    setFiles(filesArray);
    setProgress(0);
    setUploadStarted(false);
  }, []);

Next up, we need a handler for deleting files that are already selected.

const handleClearFile = useCallback((id) => {
    setFiles((prev) => prev.filter((file) => file.id !== id));
  }, []);

We need to show the progress bar only when the upload process is happening. Let's create a memoized function to figure out whether the upload is done or not.

const canShowProgress = useMemo(() => files.length > 0, [files.length]);

And now let’s do the same for whether the upload is complete:

const uploadComplete = useMemo(() => progress === 100, [progress]);

As we talked about at the beginning of this tutorial, we will also use axios to add an upload feature. For this purpose, let's create a new method called handleUpload.

const handleUpload = useCallback(async () => {
    try {
      const data = new FormData();

      files.forEach((file) => {
        data.append("file", file.file);
      });

      const res = await axios.request({
        url: uploadURL,
        method: "POST",
        data,
        onUploadProgress: (progressEvent) => {
          setUploadStarted(true);
          const percentCompleted = Math.round(
            (progressEvent.loaded * 100) / progressEvent.total
          );
          setProgress(percentCompleted);
        },
      });

      setUploadStarted(false);
      console.log(res);
    } catch (error) {
      console.log(error);
    }
  }, [files.length]);

The code above builds the data using the FormData construct and uses the Axios library to upload files. onUploadProgress is a special method property of Axios that gets called with a progress event. We will use this method to update the overall progress of the upload.

setProgress(percentCompleted);

Putting it all together:

// file-picker.jsx

import axios from "axios";
import classNames from "classnames";
import { nanoid } from "nanoid";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { DropZone } from "./drop-zone";
import styles from "./file-picker.module.css";
import { FilesList } from "./files-list";

const FilePicker = ({ accept, uploadURL }) => {

  const [files, setFiles] = useState([]);
  const [progress, setProgress] = useState(0);
  const [uploadStarted, setUploadStarted] = useState(false);

  // handler called when files are selected via the Dropzone component
  const handleOnChange = useCallback((files) => {
    let filesArray = Array.from(files);

    filesArray = filesArray.map((file) => ({
      id: nanoid(),
      file,
    }));

    setFiles(filesArray);
    setProgress(0);
    setUploadStarted(false);
  }, []);

  // handle for removing files form the files list view
  const handleClearFile = useCallback((id) => {
    setFiles((prev) => prev.filter((file) => file.id !== id));
  }, []);

  // whether to show the progress bar or not
  const canShowProgress = useMemo(() => files.length > 0, [files.length]);

  // execute the upload operation
  const handleUpload = useCallback(async () => {
    try {
      const data = new FormData();

      files.forEach((file) => {
        data.append("file", file.file);
      });

      const res = await axios.request({
        url: uploadURL,
        method: "POST",
        data,
        onUploadProgress: (progressEvent) => {
          setUploadStarted(true);
          const percentCompleted = Math.round(
            (progressEvent.loaded * 100) / progressEvent.total
          );
          setProgress(percentCompleted);
        },
      });
      
    } catch (error) {
      console.log(error);
    }
  }, [files.length]);

  // set progress to zero when there are no files
  useEffect(() => {
    if (files.length < 1) {
      setProgress(0);
    }
  }, [files.length]);

  // set uploadStarted to false when the upload is complete
  useEffect(() => {
    if (progress === 100) {
      setUploadStarted(false);
    }
  }, [progress]);

  const uploadComplete = useMemo(() => progress === 100, [progress]);

  return (
    <div className={styles.wrapper}>
      {/* canvas */}
      <div className={styles.canvas_wrapper}>
        <DropZone onChange={handleOnChange} accept={accept} />
      </div>

      {/* files listing */}
      {files.length ? (
        <div className={styles.files_list_wrapper}>
          <FilesList
            files={files}
            onClear={handleClearFile}
            uploadComplete={uploadComplete}
          />
        </div>
      ) : null}

      {/* progress bar */}
      {canShowProgress ? (
        <div className={styles.files_list_progress_wrapper}>
          <progress value={progress} max={100} style={{ width: "100%" }} />
        </div>
      ) : null}

      {/* upload button */}
      {files.length ? (
        <button
          onClick={handleUpload}
          className={classNames(
            styles.upload_button,
            uploadComplete || uploadStarted ? styles.disabled : ""
          )}
        >
          {`Upload ${files.length} Files`}
        </button>
      ) : null}
    </div>
  );
};

export { FilePicker };

Finally, the styles for the FilePicker component.

// file-picker.module.css

.wrapper {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  width: 600px;
  padding: 1rem;
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
  border-radius: 4px;
}

.canvas_wrapper {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
}

.files_list_wrapper {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
}

.files_list_progress_wrapper {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  margin: 1rem 0;
}

.upload_button {
  min-width: 100px;
  height: 40px;
  border: none;
  border-radius: 4px;
  background-color: #0073cf;
  color: #fff;
  font-size: 1rem;
  font-weight: 500;
  cursor: pointer;
  outline: none;
  white-space: nowrap;
  padding: 0.25rem 0.5rem;
}

.upload_button.disabled {
   background-color: #ccc;
   color: #fff;
   cursor: not-allowed;
}

File picker in action

We'll use http://dlptest.com/http-post/ to test our file uploads for demonstration purposes.

Browsers like Chrome block cross-origin requests due to security concerns. We can temporarily turn off the CORS check while in development mode and let file upload requests go through.

Before running the tutorial app, allow Chrome to make a cross-origin request by following the steps outlined in this article. This step should only be used strictly during development, and it shouldn't be used to access websites or web apps in general.

import { FilePicker } from './components/file-picker'

function App() {

  return (
    <div className="App">
      <FilePicker uploadURL={"http://dlptest.com/http-post/"} />
    </div>
  );
}

export default App

Conclusion

That’s it! All the code is in this sandbox for your reference.