BlogReact Multiple Image Upload Component for Firebase Storage

React Multiple Image Upload Component for Firebase Storage

Using MaterialUI & react-dropzone

Topics:

    React

    Material UI

    Firebase

Read Time: 30 min

Published: 2021-02-03

Last Updated: 2021-02-03

In this post I am going to show how to build a React-Component with which you can upload multiple images to a Firebase Storage Bucket.
I am going to use Material UI and the NPM-Package react-dropzone to achieve this.

First of all lets define the requirements which I expect my application to fulfill:

  • The user should be able to select the files for upload with a classical file-selection dialog, as well as by using Drag&Drop (Selecting multiple images has to be possible)
  • The order of the uploaded images should be editable after upload (The order might be important for the rest of your application)
  • It should be possible to delete individual images after upload
  • Each image should have a description field for the user to type in

Here is a link to the final code:
https://github.com/Develrockment/React-Multiple-Image-Upload-Firebase

Setup

Project

We start of with a classical create-react-app:

npx create-react-app react-multiple-images-upload

We delete the contents of App.js in order to start with a completely blank project:

JSX

export default function App() {
   return (
      <div>
         <p>React Multiple Images Upload</p>
      </div>
   );
}

Next we install MaterialUI & react-dropzone.
MaterialUI is a very popular CSS-Framework which will help us to get our styling done much faster.
react-dropzone is a ready-to-use component for image-uploading which supports Drag&Drop.

npm install @material-ui/core react-dropzone

Firebase

We are going to use Firebase as our Backend to our project.
It is a Google-Product, so you can use your Google-Account to log-in.
I am not going to deep into how Firebase works. If you are not familiar with this product you can read up in their documentation.
Firebase has a "Spark Plan" which is free and can be used for development purposes.

I am creating a new project with the name "React-Multiple-Images-Upload".
A Firebase-Project could theoretically be connected to multiple different Apps(Android, IOS, Web-Apps, ...) which all share the same Backend.
We are going to build a Web-App, so we need to connect one to the Firebase-Project.
In our Firebase-Web-Console we navigate to "Project Settings" (In the menu next to "Project Overview"), where we scroll down and click on the button for adding a new Web-App:

In the next dialog we need to define a name for our App. I am choosing "React-Multiple-Images-Upload" again.
After we registered our App, a dialog is shown in which we can see the Firebase Configuration Data. We will create a file .env in our project in order to use the configuration as environment variables like this:

JAVASCRIPT

REACT_APP_FIREBASE_PUBLIC_API_KEY=xxxxx
REACT_APP_FIREBASE_AUTH_DOMAIN=xxxxx
REACT_APP_FIREBASE_PROJECT_ID=xxxxx
REACT_APP_FIREBASE_STORAGE_BUCKET=xxxxx
REACT_APP_FIREBASE_MESSAGING_SENDER_ID=xxxxx
REACT_APP_FIREBASE_APP_ID=xxxxx

Of course you need to fill out the data for YOUR project.
The names of the environment variables need to be prefixed with REACT_APP_ in order for React to include them in the build process, so they can be used in the frontend-code.
Be advised that because of that, the stored values are public information once you published your App. But for the firebase configuration this is intentional.

We will save the uploaded images to Firebase Storage.
In order to activate the Storage we navigate in our Firebase-Web-Console to "Storage", found under "Build". Here we click on "Get started":

In the next dialog we can read something about security rules for the Firebase-Storage:

Firebase Security Rules is the system with which you can define how your database can be accessed.
I am not covering this topic in this post, if you want to know more about it keep reading in the Firebase Documentation.
Just click "Next" for now.

In the next dialog we are asked to choose a location for our storage bucket:

This is important mainly for performance reasons. Choose a location that is close to you. If you want to know more about that, the documentation is your friend once more.

For the next step we are going to deactivate the Firebase Security Rules for the Storage, so they don't interfere with the development of our application.
Since we are already in the Storage-Menu we click on "Rules":

We set allow read, write: if true; in order to allow all read- and write-requests to the Firebase-Storage.
(Attention: Don't use this configuration in a production App!)

And we click "Publish".

Finally we need to install the Firebase SDK to our project by running:

npm install --save firebase

After that we connect our App to the actual Firebase Backend.
In order to achieve that, we create a new file in our project: firebase.js

JAVASCRIPT

import firebase from "firebase/app";
import "firebase/storage";

if (!firebase.apps.length) {
   firebase.initializeApp({
      apiKey: process.env.REACT_APP_FIREBASE_PUBLIC_API_KEY,
      authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN,
      databaseURL: process.env.REACT_APP_FIREBASE_DB_URL,
      projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID,
      storageBucket: process.env.REACT_APP_FIREBASE_STORAGE_BUCKET,
      messagingSenderId: process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID,
      appId: process.env.REACT_APP_FIREBASE_APP_ID,
   });
}

const storage = firebase.storage();

export { firebase, storage };

With firebase.apps.length we are checking if there are active Firebase-Apps in our project. If there are none, we initialize one with our configuration-data stored in the environment variables.
This prevents possible problems which could arise when there are multiple apps initialized.
We export the firebase-library itself, as well as the instance of the storage(Bucket where our uploaded images will be saved).
We will use this methods in our project in order to communicate with the Firebase Backend.

The Code

Uploading Images

We are going to build 3 components:

  • App.js - The Main Container Component
  • ImageElement.js - Representing one uploaded image
  • ImagesDropzone.js - The dropzone/image upload component

Lets start off with App.js:

JSX

import React, { useState, useEffect } from "react";

import { Grid, Box } from "@material-ui/core";

import ImagesDropzone from "./imagesDropzone";
import ImageElement from "./imageElement";

export default function App() {
   const [imageList, setImageList] = useState([]);

   const changeImageField = (index, parameter, value) => {
      const newArray = [...imageList];
      newArray[index][parameter] = value;
      setImageList(newArray);
   };

   useEffect(() => {
      imageList.forEach((image, index) => {
         if (image.status === "FINISH" || image.status === "UPLOADING") return;
         changeImageField(index, "status", "UPLOADING");
         const uploadTask = image.storageRef.put(image.file);
         uploadTask.on(
            "state_changed",
            null,
            function error(err) {
               console.log("Error Image Upload:", err);
            },
            async function complete() {
               const downloadURL = await uploadTask.snapshot.ref.getDownloadURL();
               changeImageField(index, "downloadURL", downloadURL);
               changeImageField(index, "status", "FINISH");
            }
         );
      });
   });

   return (
      <Grid container direction="column" alignItems="center" spacing={2}>
         <Box border={1} margin={4} padding={3}>
            <Grid
               item
               container
               direction="column"
               alignItems="center"
               xs={12}
               spacing={1}
            >
               <Grid item container xs={12} justify="center">
                  <ImagesDropzone setImageList={setImageList} />
               </Grid>
            </Grid>
         </Box>
         {imageList.length > 0 && (
            <Box bgcolor="primary.light" p={4}>
               {imageList.map((image, index) => {
                  return (
                     <Grid item key={image.file.size + index}>
                        <ImageElement
                           image={image}
                           index={index}
                        />
                     </Grid>
                  );
               })}
            </Box>
         )}
      </Grid>
   );
}

We are using the Grid and Box components from MaterialUI for our main layout.
Read in the MaterialUI-Documentation if you want to know more about how these work.

imageList represents an array of our uploaded images.
changeImageField is a function which allows us to change the parameters of the individual elements in imageList.
It takes the index of the element to change, the parameter which should be changed and the new value for that parameter.

In useEffect() we are waiting for changes to imageList, in order to start the upload-process of newly added images.
(The images will be added in the ImagesDropzone component)
We are looping over the Array and if there is a newly uploaded image it will have a status of CREATED.
When the status is FINISH or UPLOADING there is no need to start the upload-process anymore, so we can return from the function.
Otherwise we will generate an uploadTask from the storageRef of the image.
The storageRef is a reference to the File in the Firebase Storage, which is added in the ImagesDropzone component. You can read more about how that works here.
Using the uploadTask.on() we can define observers, which react to state changes of the upload-process of the image.
We can pass in 3 functions:

  1. React to changes of the upload-process, like the number of bytes already uploaded or the total number of bytes which will be uploaded for that file. I am passing null here, because i will not work with these values for this example, but you can build a progress-bar with these.
  2. React to an error-event in the upload-process.
  3. React to the upload being complete. When this happens we read the URL of the image with uploadTask.snapshot.ref.getDownloadURL() and save that value to our image object using our changeImageField() function.
    Also we set the status to FINISH so no new uploadTask will be created for that image.

We simply use imageList.map() to iterate over the imageList Array, so the images can be rendered using the ImageElement component.

Now we continue building our ImagesDropzone component.
As you can see in the code above it gets the setImagesList() function as a prop in order to fill the state with the array of images.
The code looks like this:

JSX

import React from "react";
import { firebase } from "./firebase";
import { useDropzone } from "react-dropzone";
import { Grid, Typography, Button } from "@material-ui/core";

export default function ImagesDropzone({ setImageList }) {
   const onDrop = (acceptedFiles) => {
      if (acceptedFiles.length > 0) {
         const newImages = Array.from(acceptedFiles).map((file) => {
            return {
               file: file,
               fileName: file.name,
               status: "CREATED",
               storageRef: firebase.storage().ref().child(file.name),
               downloadURL: "",
               description: "",
            };
         });

         setImageList((prevState) => [...prevState, ...newImages]);
      }
   };

   const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
      onDrop,
      accept: "image/png, image/jpeg",
      noClick: true,
      noKeyboard: true,
   });

   return (
      <div {...getRootProps()}>
         <input {...getInputProps()} />
         <Grid container direction="column" spacing={2}>
            <Grid
               item
               container
               direction="column"
               alignItems="center"
               spacing={1}
            >
               <Grid item>
                  <Typography align="center">
                     {isDragActive
                        ? "Drop Images here ..."
                        : "Drag 'n' drop Images here, or:"}
                  </Typography>
               </Grid>
               <Grid item>
                  <Button onClick={open} variant="contained" color="primary">
                     Select Images...
                  </Button>
               </Grid>
            </Grid>
         </Grid>
      </div>
   );
}

As I mentioned earlier, we will use the react-dropzone package for this component.
You can read up on how it works in detail here.

The package exposes a custom hook useDropzone which accepts options in form of an object.
We define accept: "image/png, image/jpeg" to allow only PNG- and JPEG-Images to be uploaded, noClick: true to prevent the file-selection dialog popup when clicking the component (We will handle this with a dedicated button) and noKeyboard: true to prevent keyboard-selection of the component (The button will be keyboard selectable).
We also give the function onDrop to the useDropzone hook. This function will be called when there are images Drag&Dropped on the component as well as when the File-Dialog is used.

The useDropzone hook exposes several parameters. We will use getRootProps, getInputProps, isDragActive and open.
getRootProps and getInputProps are used to supply our components with all the necessary props.
isDragActive is a boolean value which is set true when the user is dragging some files over the component. We use this value to present a message to the user "Drop Images here...".
open is a function which opens the classical file-selection dialog when triggered.
We will use this in the onClick() of our button with the message "Select Images...".

In the onDrop() function itself we convert the file-list to an Array using Array.from().
After that we map() over the Array to generate the objects representing the uploaded images in state.
As I mentioned earlier we set the status to CREATED in order for our App component to start the upload-process.
We use firebase.storage().ref().child(file.name) to generate a reference to the Firebase Storage with the name of our file.
If you want to overwrite the name of the file you can to that here.
Finally we use a callback with setImageList() to append the selected images to other images which might have been selected before.

At this stage our component should look like this:

Next we are going to take a look at the component which will be responsible for displaying our uploaded images, imageElement:

JSX

import React from "react";

import {
   Paper,
   Grid,
   CircularProgress,
   Box
} from "@material-ui/core";

export default function ImageElement({ image, index }) {
   return (
      <Box my={2} width={400}>
         <Paper>
            <Grid container direction="row" justify="center" spacing={2}>
               <Grid item container alignItems="center" justify="center">
                  {image.downloadURL ? (
                     <img
                        src={image.downloadURL}
                        alt={`Upload Preview ${index + 1}`}
                        style={{
                           maxHeight: "100%",
                           maxWidth: "100%"
                        }}
                     />
                  ) : (
                     <Box p={2}>
                        <CircularProgress />
                     </Box>
                  )}
               </Grid>
            </Grid>
         </Paper>
      </Box>
   );
}

This component is actually very simple.
If there is a downloadURL available (Added by App after the upload-process is finished) it displays the image. Otherwise we show a loading spinner.

At this point our application should be functional to the point that you can Drag&Drop images on the imagesDropzone component or select them from the file-menu.
The images should get uploaded successfully and will be displayed in the Frontend like this:

Changing the order of the images

Now we will add the functionality to change the order of the already uploaded images.
We will use react-icons to generate icons for the imageElement component for the user to click on:

npm install react-icons

We start of by defining two new functions in our App component:

JSX

  const handleChangeOrderUp = (index) => {
    // If first, ignore
    if (index !== 0) {
       const newArray = [...imageList];
       const intermediate = newArray[index - 1];
       newArray[index - 1] = newArray[index];
       newArray[index] = intermediate;
       setImageList(newArray);
    }
  };

  const handleChangeOrderDown = (index) => {
    // If last, ignore
    if (index < imageList.length - 1) {
       const newArray = [...imageList];
       const intermediate = newArray[index + 1];
       newArray[index + 1] = newArray[index];
       newArray[index] = intermediate;
       setImageList(newArray);
    }
  };

handleChangeOrderUp and handleChangeOrderDown take a index of an element in the imageList Array and switch the position one to the left(up) or to the right(down) accordingly, by using an intermediate variable.
If the element to change is already at the first or last position of the Array the function will not execute anything.

We pass these functions to ImageElement. We also define two boolean values which will tell the component if the current element is the first or the last one in the imageList Array. We will use this information to show the corresponding options to change the position in the imageElement component (this will become clear later).

JSX

 <ImageElement
    image={image}
    index={index}
    isFirstElement={index === 0}
    isLastElement={index === imageList.length - 1}
    handleChangeOrderUp={handleChangeOrderUp}
    handleChangeOrderDown={handleChangeOrderDown}
  />

We continue in the imageElement component. We add:

JSX

import React from "react";

import {
   Paper,
   Grid,
   CircularProgress,
   Box,
   IconButton
} from "@material-ui/core";

import { IoIosArrowUp, IoIosArrowDown } from "react-icons/io";

export default function ImageElement({
   image,
   index,
   isFirstElement,
   isLastElement,
   handleChangeOrderUp,
   handleChangeOrderDown,
}) {
   return (
      <Box my={2} width={400}>
         <Paper>
            <Grid container direction="row" justify="center" spacing={2}>
               <Grid
                  item
                  container
                  alignItems="center"
                  justify="center"
                  xs={10}
               >
                  {image.downloadURL ? (
                     <img
                        src={image.downloadURL}
                        alt={`Upload Preview ${index + 1}`}
                        style={{
                           maxHeight: "100%",
                           maxWidth: "100%",
                        }}
                     />
                  ) : (
                     <Box p={2}>
                        <CircularProgress />
                     </Box>
                  )}
               </Grid>
               <Grid
                  container
                  direction="column"
                  alignItems="center"
                  justify="center"
                  item
                  xs={2}
               >
                  <Grid item container alignItems="center" justify="center">
                     {!isFirstElement && (
                        <IconButton
                           aria-label="Image up"
                           onClick={() => handleChangeOrderUp(index)}
                        >
                           <IoIosArrowUp />
                        </IconButton>
                     )}
                  </Grid>
                  <Grid item container alignItems="center" justify="center">
                     {!isLastElement && (
                        <IconButton
                           aria-label="Image down"
                           onClick={() => handleChangeOrderDown(index)}
                        >
                           <IoIosArrowDown />
                        </IconButton>
                     )}
                  </Grid>
               </Grid>
            </Grid>
         </Paper>
      </Box>
   );
}

We are using the MaterialUI Grid system for the layout of our component.
Depending on if the element isFirstElement or isLastElement we render an up-buttom, a down-button or both. The onClick() of these buttons call our previously defined functions which change the order of our images in imageList.

At this point our application should be able to change the order of the images using the newly defined buttons:


Deleting Images

Next we are going to enable the user to delete images after uploading.
We start by adding a new function handleDeleteImage to our App component:

JSX

 const handleDeleteImage = (index) => {
    imageList[index].storageRef
       .delete()
       .then(() => {
          const newArray = [...imageList];
          newArray.splice(index, 1);
          setImageList(newArray);
       })
       .catch((error) => {
          console.log("Error deleting file:", error);
       });
  };

We are using the reference to the image in the Firebase Storage saved in storageRef to call delete(), which deletes the file in the database.
After that we use splice(index, i) to remove the image element from imageList so it won`t be displayed in our Frontend anymore.

Now we are going to pass this function down to the ImageElement component and once more create an appropriate button for calling the function:
(I am leaving out some of the already existing code now, no point in reading the same thing over and over again)

JSX

import React from "react";

import {
   Paper,
   Grid,
   CircularProgress,
   Box,
   IconButton,
} from "@material-ui/core";

import { IoIosArrowUp, IoIosArrowDown } from "react-icons/io";
import { RiDeleteBin5Line } from "react-icons/ri";

export default function ImageElement({
   image,
   index,
   isFirstElement,
   isLastElement,
   handleChangeOrderUp,
   handleChangeOrderDown,
   handleDeleteImage,
}) {
...
...
...
<Grid
   container
   direction="column"
   alignItems="center"
   justify="center"
   item
   xs={2}
>
   <Grid item container alignItems="center" justify="center">
      {!isFirstElement && (
         <IconButton
            aria-label="Image up"
            onClick={() => handleChangeOrderUp(index)}
         >
            <IoIosArrowUp />
         </IconButton>
      )}
   </Grid>
   <Grid item container alignItems="center" justify="center" xs={4}>
      <IconButton
         aria-label="Delete Image"
         onClick={() => handleDeleteImage(index)}
      >
         <RiDeleteBin5Line />
      </IconButton>
   </Grid>
   <Grid item container alignItems="center" justify="center">
      {!isLastElement && (
         <IconButton
            aria-label="Image down"
            onClick={() => handleChangeOrderDown(index)}
         >
            <IoIosArrowDown />
         </IconButton>
      )}
   </Grid>
</Grid>
...
...
...

Now it is possible to delete an uploaded image in our application with the new delete-button:


Adding a description field

In our final step we are going to add a description field to each image element.
We don't need to add any extra code to the App component, we are just going to use the changeImageField() function which we created earlier and pass it down to our ImageElement component:

JSX

import React from "react";

import {
   Paper,
   Grid,
   CircularProgress,
   Box,
   TextField,
   IconButton,
} from "@material-ui/core";

import { IoIosArrowUp, IoIosArrowDown } from "react-icons/io";
import { RiDeleteBin5Line } from "react-icons/ri";

export default function ImageElement({
   image,
   index,
   isFirstElement,
   isLastElement,
   handleChangeOrderUp,
   handleChangeOrderDown,
   handleDeleteImage,
   changeImageField,
}) {
   return (
      <Box my={2} width={600}>
         <Paper>
            <Grid container direction="row" justify="center" spacing={2}>
               <Grid 
                 item 
                 container 
                alignItems="center" 
                justify="center" 
                xs={6}
               >
                  {image.downloadURL ? (
                     <img
                        src={image.downloadURL}
                        alt={`Upload Preview ${index + 1}`}
                        style={{
                           maxHeight: "100%",
                           maxWidth: "100%",
                        }}
                     />
                  ) : (
                     <Box p={2}>
                        <CircularProgress />
                     </Box>
                  )}
               </Grid>
               <Grid item container alignItems="center" xs={4}>
                  <TextField
                     multiline
                     size="small"
                     rows={4}
                     fullWidth
                     variant="outlined"
                     value={image.description}
                     onChange={(event) => {
                        changeImageField(
                           index,
                           "description",
                           event.target.value
                        );
                     }}
                  />
               </Grid>
               <Grid
                  container
                  direction="column"
                  alignItems="center"
                  justify="center"
                  item
                  xs={2}
               >
                  <Grid item container alignItems="center" justify="center">
                     {!isFirstElement && (
                        <IconButton
                           aria-label="Image up"
                           onClick={() => handleChangeOrderUp(index)}
                        >
                           <IoIosArrowUp />
                        </IconButton>
                     )}
                  </Grid>
                  <Grid
                     item
                     container
                     alignItems="center"
                     justify="center"
                     xs={4}
                  >
                     <IconButton
                        aria-label="Delete Image"
                        onClick={() => handleDeleteImage(index)}
                     >
                        <RiDeleteBin5Line />
                     </IconButton>
                  </Grid>
                  <Grid item container alignItems="center" justify="center">
                     {!isLastElement && (
                        <IconButton
                           aria-label="Image down"
                           onClick={() => handleChangeOrderDown(index)}
                        >
                           <IoIosArrowDown />
                        </IconButton>
                     )}
                  </Grid>
               </Grid>
            </Grid>
         </Paper>
      </Box>
   );
}

Setting width={600} on the Box element we widen the component to generate space for the description text-input.
Also we adjust the layout of the Grid elements in order to give them a meaningful distribution in the component.
Finally we use the TextField component from MaterialUI to generate our text-input and set the description field in our image-object in state.

Now we can add an individual description for each of the uploaded images in the Frontend:


Further Improvements

Here are some further improvements that you can look for from here on:

  • Keep in mind, that it is not possible to save the order of the images in Firebase Storage, because it is just a "dumb" file-container. In order to retain the image-order you need to save it in a database as a final step. Because we already setup a firebase project it might be a good idea to use Cloud Firestore to achieve that. ​
  • It might be a good idea to limit the number of files a user can upload, as well as the maximum size of one file. You can do this by appending a .filter() function after Array.from(acceptedFiles) in the onDrop() function inside of the ImagesDropzone component.
  • When the delete button of an image element is clicked, it might take a moment for Firebase Storage to respond. I would recommend adding a loading spinner to the component, so that the user does not experiences an unresponsive interface. You can achieve this by displaying the spinner when handleDeleteImage() is called and remove it when the .then() block is executed after the deletion.
  • As i mentioned earlier you can listen to the number of actually uploaded bytes by passing a function to the second argument of uploadTask.on(). You could use this information to build a progressbar which indicates the progress of the individual image uploads. CircularProgress from MaterialUI actually supports this use case, but in my opinion since images are usually not that big, showing the progress is not really worth it.
  • useDropzone() exports the boolean values isDragAccept and isDragReject. You could use these to further improve the user expirience by giving feedback if the currently dragged items are accepted for upload or not.
  • We did not take care of error handling in our example at all. Of course that would be necessary in a real world application as well

Thank you very much for taking the time an reading that far :-)

Here you can find the code for the whole example:
https://github.com/Develrockment/React-Multiple-Image-Upload-Firebase