Web-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:
- 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. - React to an error-event in the upload-process.
- 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 ourchangeImageField()
function.
Also we set thestatus
toFINISH
so no newuploadTask
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 theonDrop()
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 valuesisDragAccept
andisDragReject
. 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