Ever tried to upload a file? On most websites, when you click on the submit button on a file upload form, you get the feeling of being stuck in limbo because the page just loads until the upload is done. If you are uploading your file on a slow connection, what you get is

Stuck

In this guide, we will take a different approach to file uploads by displaying the actual progress of an upload.

Note: I assume some familiarity with React and TypeScript/Javascript. Please feel free to learn more about them by using the links at the bottom of this guide.


Let's go ahead and bootstrap a React app using create-react-app

$ npx create-react-app my-app --template typescript

When the installation is completed, cd into the project directory and run the following command

$ yarn add axios react-circular-progressbar

to install Axios and a React progressbar component (there are tons of progress indicators for React on NPM!). Axios is our HTTP client for making requests to our app's API. We will not be concerned with the implementation details of an API at the moment, so I've gone ahead to mock responses for a successful and a failed request.

When that's done, let's go straight to writing code. Our project folder should look something like this:

├── README.md
├── package.json
├── public
│   ├── favicon.ico
│   ├── index.html
│   ├── logo192.png
│   ├── logo512.png
│   ├── manifest.json
│   └── robots.txt
├── src
│   ├── App.css
│   ├── App.test.tsx
│   ├── App.tsx
│   ├── index.css
│   ├── index.tsx
│   ├── logo.svg
│   ├── react-app-env.d.ts
│   ├── serviceWorker.ts
│   └── setupTests.ts
├── tsconfig.json
└── yarn.lock

Open up App.tsx and replace the contents with this:

import React, { FC } from 'react';
import './App.css';

const App: FC = (): JSX.Element => {
    return (
        <div className="app">
            <div className="image-preview-box">
            </div>

            <form className="form">
                <button className="file-chooser-button" type="button">
                    Choose File
                    <input
                        className="file-input"
                        type="file"
                        name="file" />
                </button>
                <button className="upload-button" type="submit">
                    Upload
                </button>
            </form>
        </div>
    );
}

export default App;

What we have now is an empty div for previewing an uploaded image and a form setup with a file input. Let's add some CSS to make things pretty.

Pretty gif


Open the App.css file and replace the existing contents with the following:

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

.image-preview-box {
    width: 200px;
    height: 200px;
    border: 1px solid rgba(0,0,0,0.3);
    text-align: center;
    display: flex;
    justify-content: center;
    align-items: center;
}

.form {
    display: flex;
    flex-direction: column;
    position: relative;
}

.form > * {
    margin: 0.5em auto;
}

.file-chooser-button {
    border: 1px solid teal;
    padding: 0.6em 2em;
    position: relative;
    color: teal;
    background: none;
}

.file-input {
    position: absolute;
    opacity: 0;
    top: 0;
    left: 0;
    height: 100%;
    width: 100%;
}

.upload-button {
    background: teal;
    border: 1px solid teal;
    color: #fff;
    padding: 0.6em 2em;
}

Now let's go back to the template and set up our input to only accept images.

Add the following to the top of our component:

+ const [file, setFile] = useState();

Change the following in App.tsx:

- <input
-    className="file-input"
-    type="file"
-    name="file" />
+ <input
+    className="file-input"
+    type="file"
+    name="file"
+    accept={acceptedTypes.toString()}
+    onChange={(e) => {
+        if (e.target.files && e.target.files.length > 0) {
+            setFile(e.target.files[0])
+        }
+    }} />

We are currently selecting a file from the user's device and saving the file to the Function Component state if it passes validation. The accept attribute value is a string that defines the file types the file input should accept. This string is a comma-separated list of unique file type specifiers. The files attribute is a FileList object that lists every selected file (only one, unless the multiple attribute is specified).1

For flexibility, you can add this array just after the last line of imports in App.tsx:

const acceptedTypes: string[] = [
    'image/png',
    'image/jpg',
    'image/jpeg',
];

Next, we will import Axios and attempt to submit the user selected file to our (mock) API. Add the axios import:

+ import axios from 'axios';

and add the following code at the top of the App component:

const [uploadProgress, updateUploadProgress] = useState(0);
const [imageURI, setImageURI] = useState<string|null>(null);
const [uploadStatus, setUploadStatus] = useState(false);
const [uploading, setUploading] = useState(false);

const getBase64 = (img: Blob, callback: any) => {
    const reader = new FileReader();
    reader.addEventListener('load', () => callback(reader.result));
    reader.readAsDataURL(img);
}

const isValidFileType = (fileType: string): boolean => {
    return acceptedTypes.includes(fileType);
};

const handleFileUpload = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    if (!isValidFileType(file.type)) {
        alert('Only images are allowed (png or jpg)');
        return;
    }

    setUploading(true);
    const formData = new FormData();
    formData.append('file', file);

    axios({
        method: 'post',
        headers: {
            'Content-Type': 'multipart/form-data',
        },
        data: formData,
        url: 'http://www.mocky.io/v2/5e29b0b93000006500faf227',
        onUploadProgress: (ev: ProgressEvent) => {
            const progress = ev.loaded / ev.total * 100;
            updateUploadProgress(Math.round(progress));
        },
    })
    .then((resp) => {
        // our mocked response will always return true
        // in practice, you would want to use the actual response object
        setUploadStatus(true);
        setUploading(false);
        getBase64(file, (uri: string) => {
            setImageURI(uri);
        });
    })
    .catch((err) => console.error(err));
};

It feels like a lot is going on here, but all we are doing is

  • preventing the default form submit action
  • validating the file type using Javascript (¯_(ツ)_/¯)
  • creating a FormData object and adding the file we have in state to the object
  • submitting an axios POST request
  • getting the current upload progress and saving it as a percentage value to our app's state using axios' onUploadProgress() config option
  • marking the upload as done in our state (useful later to show our photo preview)
  • and making sure None Shall Pass™

Of course we will need to update our form to account for the new changes:

- <form className="form">
+ <form onSubmit={handleFileUpload} className="form">

We will also need to update the empty div and make it show a preview of our uploaded file:

<div className="image-preview-box">
+ {(uploadStatus && imageURI)
+     ? <img src={imageURI} alt="preview" />
+     : <span>A preview of your photo will appear here.</span>
+ }
</div>

To wrap things up, let's import our progress component and set it up. First, add the following to the app's imports:

+ import { CircularProgressbar, buildStyles } from 'react-circular-progressbar';
+ import "react-circular-progressbar/dist/styles.css";

Then add this just after the closing </form> tag:

{(uploading)
    ?
    <div className="progress-bar-container">
        <CircularProgressbar
            value={uploadProgress}
            text={`${uploadProgress}% uploaded`}
            styles={buildStyles({
                textSize: '10px',
                pathColor: 'teal',
            })}
        />
    </div>
    : null
}

All done! We have been able to inspect and show our users what happens with their upload as it happens. We can extend this further by making it possible for users to cancel their uploads2 if it's progressing slowly.

You can find the project source code here. Feel free to check it out and let me know what you think in the comments.

References

  1. HTML input element on MDN
  2. Axios docs