Building a file picker component in React

Prabhu Murthy
Prabhu Murthy
Contributor

Jun 21, 2022

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.

1 
2npx create-react-app file-picker
3

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

1 
2cd ./file-picker
3npm install
4npm run start
5

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.
1npm install classnames axios nanoid
2
3

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.

1 
2cd src/
3mkdir components
4cd components/
5

Here are all of the components and their associated CSS modules that we’ll create in this 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.

1// drop-zone.jsx
2
3import React from "react";
4import styles from "./drop-zone.module.css";
5
6const Banner = ({ onClick, onDrop }) => {
7  const handleDragOver = (ev) => {
8    ev.preventDefault();
9    ev.stopPropagation();
10    ev.dataTransfer.dropEffect = "copy";
11  };
12
13  const handleDrop = (ev) => {
14    ev.preventDefault();
15    ev.stopPropagation();
16    onDrop(ev.dataTransfer.files);
17  };
18
19
20
21  return (
22    <div
23      className={styles.banner}
24      onClick={onClick}
25      onDragOver={handleDragOver}
26      onDrop={handleDrop}
27    >files       <span className={styles.banner_text}>Click to Add files </span>
28      <span className={styles.banner_text}>Or</span>
29      <span className={styles.banner_text}>Drag and Drop files here</span>
30    </div>
31  );
32};
33
34

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.

1const handleDragOver = (ev) => {
2    ev.preventDefault();
3    ev.stopPropagation();
4    ev.dataTransfer.dropEffect = "copy";
5  };
6
7

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

1const handleDrop = (ev) => {
2  ev.preventDefault();
3  ev.stopPropagation();
4  onDrop(ev.dataTransfer.files);
5};
6

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.

1// drop-zone.jsx
2
3import styles from "./drop-zone.module.css";
4
5const DropZone = ({ onChange, accept = ["*"] }) => {
6  const inputRef = React.useRef(null);
7
8  const handleClick = () => {
9    inputRef.current.click();
10  };
11
12  const handleChange = (ev) => {
13    onChange(ev.target.files);
14  };
15
16  const handleDrop = (files) => {
17    onChange(files);
18  };
19
20  return (
21    <div className={styles.wrapper}>
22      <Banner onClick={handleClick} onDrop={handleDrop} />
23      <input
24        type="file"
25        aria-label="add files"
26        className={styles.input}
27        ref={inputRef}
28        multiple="multiple"
29        onChange={handleChange}
30        accept={accept.join(",")}
31      />
32    </div>
33  );
34};
35
36export { DropZone };
37
38

The dropdown component has an input of type file defined.

1<input
2  type="file"
3  className={styles.input}
4  ref={inputRef}
5  multiple
6  onChange={handleChange}
7  accept={accept.join(",")}
8/>
9

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.

1.wrapper {
2  display: flex;
3  flex-direction: column;
4  align-items: center;
5  justify-content: center;
6  width: 100%;
7}
8
9.banner_text {
10  font-size: 1.5rem;
11  color: #ccc;
12  display: block;
13  margin: 0.5rem 0;
14}
15
16.banner {
17  background-color: #fafafa;
18  width: 100%;
19  border: 4px dashed #ccc;
20  height: 200px;
21  display: flex;
22  flex-direction: column;
23  align-items: center;
24  justify-content: center;
25  margin-bottom: 1rem;
26  cursor: pointer;
27}
28
29.input {
30  width: 100%;
31  height: 100%;
32  display: none;
33}
34
35

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.

1// file-picker/src/check.jsx 
2
3import * as React from "react";
4 
5const SvgComponent = () => (
6 <svg
7   xmlns="http://www.w3.org/2000/svg"
8   viewBox="0 0 24 24"
9   fill="none"
10   stroke="currentColor"
11   strokeWidth={2}
12   strokeLinecap="round"
13   strokeLinejoin="round"
14 >
15   <path d="M20 6 9 17l-5-5" />
16 </svg>
17);
18 
19export default SvgComponent;
20``
1// file-picker/src/clear.jsx 
2
3import * as React from "react";
4 
5const SvgComponent = () => (
6 <svg
7   xmlns="http://www.w3.org/2000/svg"
8   viewBox="0 0 24 24"
9   fill="none"
10   stroke="currentColor"
11   strokeWidth={2}
12   strokeLinecap="round"
13   strokeLinejoin="round"
14 >
15   <path d="M18 6 6 18M6 6l12 12" />
16 </svg>
17);
18 
19export default SvgComponent;
20

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:

1import CheckIcon from "../check";
2

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.

1// files-list.jsx
2
3import React, { useCallback } from "react";
4/** icons **/
5import CheckIcon from "../check";
6import ClearIcon from "../clear";
7/** styles **/
8import styles from "./files-list.module.css";
9
10const FilesListItem = ({ name, id, onClear, uploadComplete }) => {
11  const handleClear = useCallback(() => {
12    onClear(id);
13  }, []);
14
15  return (
16    <li className={styles.files_list_item}>
17      <span className={styles.files_list_item_name}>{name}</span>
18      {!uploadComplete ? (
19        <span
20          className={styles.files_list_item_clear}
21          role="button"
22          aria-label="remove file"
23          onClick={handleClear}
24        >
25          <ClearIcon />
26        </span>
27      ) : (
28        <span role="img" className={styles.file_list_item_check}>
29          <CheckIcon />
30        </span>
31      )}
32    </li>
33  );
34};
35
36const FilesList = ({ files, onClear, uploadComplete }) => {
37  return (
38    <ul className={styles.files_list}>
39      {files.map(({ file, id }) => (
40        <FilesListItem
41          name={file.name}
42          key={id}
43          id={id}
44          onClear={onClear}
45          uploadComplete={uploadComplete}
46        />
47      ))}
48    </ul>
49  );
50};
51
52export { FilesList };
53
54

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.

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

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

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

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

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

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

1import { nanoid } from "nanoid";
2 
3 const handleOnChange = useCallback((files) => {
4    let filesArray = Array.from(files);
5
6    filesArray = filesArray.map((file) => ({
7      id: nanoid(),
8      file,
9    }));
10
11    setFiles(filesArray);
12    setProgress(0);
13    setUploadStarted(false);
14  }, []);
15

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

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

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.

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

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

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

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.

1const handleUpload = useCallback(async () => {
2    try {
3      const data = new FormData();
4
5      files.forEach((file) => {
6        data.append("file", file.file);
7      });
8
9      const res = await axios.request({
10        url: uploadURL,
11        method: "POST",
12        data,
13        onUploadProgress: (progressEvent) => {
14          setUploadStarted(true);
15          const percentCompleted = Math.round(
16            (progressEvent.loaded * 100) / progressEvent.total
17          );
18          setProgress(percentCompleted);
19        },
20      });
21
22      setUploadStarted(false);
23      console.log(res);
24    } catch (error) {
25      console.log(error);
26    }
27  }, [files.length]);
28
29

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.

1setProgress(percentCompleted);
2

Putting it all together:

1// file-picker.jsx
2
3import axios from "axios";
4import classNames from "classnames";
5import { nanoid } from "nanoid";
6import React, { useCallback, useEffect, useMemo, useState } from "react";
7import { DropZone } from "./drop-zone";
8import styles from "./file-picker.module.css";
9import { FilesList } from "./files-list";
10
11const FilePicker = ({ accept, uploadURL }) => {
12
13  const [files, setFiles] = useState([]);
14  const [progress, setProgress] = useState(0);
15  const [uploadStarted, setUploadStarted] = useState(false);
16
17  // handler called when files are selected via the Dropzone component
18  const handleOnChange = useCallback((files) => {
19    let filesArray = Array.from(files);
20
21    filesArray = filesArray.map((file) => ({
22      id: nanoid(),
23      file,
24    }));
25
26    setFiles(filesArray);
27    setProgress(0);
28    setUploadStarted(false);
29  }, []);
30
31  // handle for removing files form the files list view
32  const handleClearFile = useCallback((id) => {
33    setFiles((prev) => prev.filter((file) => file.id !== id));
34  }, []);
35
36  // whether to show the progress bar or not
37  const canShowProgress = useMemo(() => files.length > 0, [files.length]);
38
39  // execute the upload operation
40  const handleUpload = useCallback(async () => {
41    try {
42      const data = new FormData();
43
44      files.forEach((file) => {
45        data.append("file", file.file);
46      });
47
48      const res = await axios.request({
49        url: uploadURL,
50        method: "POST",
51        data,
52        onUploadProgress: (progressEvent) => {
53          setUploadStarted(true);
54          const percentCompleted = Math.round(
55            (progressEvent.loaded * 100) / progressEvent.total
56          );
57          setProgress(percentCompleted);
58        },
59      });
60      
61    } catch (error) {
62      console.log(error);
63    }
64  }, [files.length]);
65
66  // set progress to zero when there are no files
67  useEffect(() => {
68    if (files.length < 1) {
69      setProgress(0);
70    }
71  }, [files.length]);
72
73  // set uploadStarted to false when the upload is complete
74  useEffect(() => {
75    if (progress === 100) {
76      setUploadStarted(false);
77    }
78  }, [progress]);
79
80  const uploadComplete = useMemo(() => progress === 100, [progress]);
81
82  return (
83    <div className={styles.wrapper}>
84      {/* canvas */}
85      <div className={styles.canvas_wrapper}>
86        <DropZone onChange={handleOnChange} accept={accept} />
87      </div>
88
89      {/* files listing */}
90      {files.length ? (
91        <div className={styles.files_list_wrapper}>
92          <FilesList
93            files={files}
94            onClear={handleClearFile}
95            uploadComplete={uploadComplete}
96          />
97        </div>
98      ) : null}
99
100      {/* progress bar */}
101      {canShowProgress ? (
102        <div className={styles.files_list_progress_wrapper}>
103          <progress value={progress} max={100} style={{ width: "100%" }} />
104        </div>
105      ) : null}
106
107      {/* upload button */}
108      {files.length ? (
109        <button
110          onClick={handleUpload}
111          className={classNames(
112            styles.upload_button,
113            uploadComplete || uploadStarted ? styles.disabled : ""
114          )}
115        >
116          {`Upload ${files.length} Files`}
117        </button>
118      ) : null}
119    </div>
120  );
121};
122
123export { FilePicker };
124
125

Finally, the styles for the FilePicker component.

1// file-picker.module.css
2
3.wrapper {
4  display: flex;
5  flex-direction: column;
6  align-items: center;
7  justify-content: center;
8  width: 600px;
9  padding: 1rem;
10  box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
11  border-radius: 4px;
12}
13
14.canvas_wrapper {
15  display: flex;
16  align-items: center;
17  justify-content: center;
18  width: 100%;
19}
20
21.files_list_wrapper {
22  display: flex;
23  align-items: center;
24  justify-content: center;
25  width: 100%;
26}
27
28.files_list_progress_wrapper {
29  display: flex;
30  align-items: center;
31  justify-content: center;
32  width: 100%;
33  margin: 1rem 0;
34}
35
36.upload_button {
37  min-width: 100px;
38  height: 40px;
39  border: none;
40  border-radius: 4px;
41  background-color: #0073cf;
42  color: #fff;
43  font-size: 1rem;
44  font-weight: 500;
45  cursor: pointer;
46  outline: none;
47  white-space: nowrap;
48  padding: 0.25rem 0.5rem;
49}
50
51.upload_button.disabled {
52   background-color: #ccc;
53   color: #fff;
54   cursor: not-allowed;
55}
56
57

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.

1import { FilePicker } from './components/file-picker'
2
3function App() {
4
5  return (
6    <div className="App">
7      <FilePicker uploadURL={"http://dlptest.com/http-post/"} />
8    </div>
9  );
10}
11
12export default App
13
14

Conclusion

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

Prabhu Murthy
Prabhu Murthy
Contributor
Jun 21, 2022
Copied