At one point or another, nearly every software development project realizes the need for an image uploader. They're used in all sorts of projects - from social media to e-commerce websites.
And yet, building a robust, re-usable image uploader isn't as simple as it may first sound. A lot like building a sidebar component with React, it comes with a few edge cases and caveats that aren't immediately obvious to spot.
That said, this guide ended up being rather long, so to make it more wieldly, it's broken up into two parts. The first part deals with building a working prototype for our code. We won't be too worried about how the component looks for now, but we'll get back to it in a future guide.
As always, if you're just interested in the code, it's on Github.
An (almost complete) list of features an image uploader should have are:
In saying this, I've come across two great alternatives if you're not for building an image uploader from scratch.
A list of dependencies we are going to need for this project are:
styled-components
- for stylingaxios
- for network requestsWe start off with a simple component that allows us to select files from our computer. In React-land, that looks like so:
import React from 'react';
function ImageUploader(){
return (
<div>
<input
type="file"
name="file"
multiple
accept="image/*"
/>
</div>
);
}
That renders a html button you've probably seen before:
Clicking on the 'Browse' button opens up a native ui file picker window. Since we've
included the multiple
prop, the user can select as many images as they please. In addition,
we've limited the types of files that can be selected to those with image extensions, using
the accept
prop.
The next problem is this button isn't possible to style 🤷. For us to style it at all,
we need to hide the default <input/>
and use our own button instead, simulating
a click event on the default <input/>
whenever our button is clicked.
In order to achieve this, we first create a reference to the default <input/>
with useRef
. Then, we
use this reference inside our own button's onClick
listener, so that any time it's
clicked, we call a click event on the hidden <input/>
.
First, our <Button/>
component:
const Button = (props: any)=> {
return (
<>
<ButtonContainer
disabled={props.disabled}
{...props}>
{props.children}
</ButtonContainer>
</>
);
}
export default Button;
const ButtonContainer = styled.button`
background: "#f40028";
border: none;
padding: 1rem 2rem;
border-radius: 4px;
color: white;
font-weight: bold;
transition: background ease-in-out 0.25s;
&:hover {
cursor: pointer;
background: #ff284b;
}
`
This produces a simple button with a nice red background that changes color on hover:
Then, lets simulate a click event on the default <input/>
when our button
is clicked:
function ImageUploader (){
// first create a null reference
+ const buttonRef = useRef<HTMLInputElement>(null);
+ const onSelectImage = ()=> {}
return (
<div>
<input
// then hide the default <input/>
+ style={{display: 'none'}}
type="file"
name="file"
// and provide a reference to the input
// we want to hide
+ ref={buttonRef}
multiple
+ onChange={onSelectImage}
/>
{/**
then call the 'click()' function on the hidden button
whenever this one is clicked
**/}
+ <Button onClick={()=> buttonRef.current?.click()}>
+ Select images
+ </Button>
</div>
);
}
export default ImageUploader;
To make everything neat and tidy, let's place both the buttons in their own internal component.
function ImageUploader(){
//..
const Buttons = ({onSelectImage})=> {
const buttonRef = useRef<HTMLInputElement>(null);
return (
<>
<input
style={{display: 'none'}}
type="file"
name="file"
ref={buttonRef}
multiple
onChange={onSelectImage}
/>
<Button onClick={()=> buttonRef.current?.click()}>
Select images
</Button>
</>
);
}
//...
}
And, in use:
function ImageUploader (){
// ...
const onSelectImage = ()=> {}
return (
<div>
- <div>
- <input
- style={{display: 'none'}}
- type="file"
- name="file"
- ref={buttonRef}
- multiple
- onChange={onSelectImage}
- />
-
- <Button onClick={()=> buttonRef.current?.click()}>
- Select images
- </Button>
- </div>
+ <Buttons onSelectImage={onSelectImage}/>
</div>
);
}
The next step is to add some logic to our onSelectImage
function. This function is
called every time a user finishes selecting one or more images.
The function should iterate over the list of files and save them in state.
In code:
import React from 'react';
type LocalFile = {
src: string,
file: File | null
}
function ImageUploader(){
const [selectedImages, setSelectedImages] = useState<LocalFile[]>([]);
const onSelectImage = (e: React.ChangeEvent<HTMLInputElement>)=> {
+ // this is how to access the list of files from the onChange event
+ const fileList: FileList | null = e.target.files;
+
+ if (fileList?.length) {
+ const newSelectedImagesArr: Array<LocalFile> =
+ Array.from(fileList).map((currentFile, i) => {
+ return {
+ file: currentFile,
+ src: URL.createObjectURL(currentFile)
+ }
+ });
+
+ setSelectedImages(newSelectedImagesArr);
+ }
}
return (
<div>
<Buttons onSelectImage={onSelectImage}/>
</div>
);
}
export default ImageUploader;
We use the Array.from
method to convert
method to convert the FileList into an array that
can be mapped.
Inside the .map()
function, we use the
URL.createObjectURL()
method from the File API
to create a URL representing each of our files file.
Every time we call the .createObjectURL()
method, it creates a new
DOMString
URL tied to the document
object in the window it was created.
We need to call URL.revokeObjectURL()
when we're done with it in order to prevent memory leaks.
The .map()
function returns an array of objects with the following shape:
type LocalFile = {
src: string,
file: File | null
}
We need both the actual file and its DOMString
URL. The URL is used
to preview our files, and the actual file will be uploaded later on.
This array of LocalFile
s is saved to state by calling the setSelectedImages()
method.
After the user has selected images that they wish to upload, they should be able to preview them and remove individual images from the list, if they wish.
First, let's create a component that wraps the <img/>
tag. This makes it easier to
style.
type ImageProps = {
src: string;
alt: string;
}
const Image = (props: ImageProps) => {
return (
<>
<ImageContainer>
<img src={props.src} alt={alt}/>
</ImageContainer>
</>
);
};
const ImageContainer = styled.div`
img {
max-width: 150px;
&:hover {
cursor:pointer;
}
}
`;
It's a simple re-usable component built using styled-components that restricts the image width to 150px and changes the cursor to a pointer when hovered.
( Note for future Brad: Incedentally, this fits in nicely with a styled-components naming convention I have an idea for. )
Next up, let's create its parent:
const ImagePreview = (props: {images: Array<LocalFile>})=> {
return (
<>
<ImagePreviewContainer>
{
props.images?.map(image=> (
<>
<Image src={image.src}/>
</>
))
}
</ImagePreviewContainer>
</>
);
}
export default ImagePreview;
const ImagePreviewContainer = styled.div`
display: flex;
gap: 1rem;
`
This component receives an images
prop, which is simply an array of LocalFile
s.
It then renders the individual images in the Image
component we created previously.
And, in use:
function ImageUploader (){
//...
return (
<div>
+ <ImagePreview images={selectedImages}/>
<Buttons onSelectImage={onSelectImage}/>
</div>
);
}
Here's what it looks like so far:
What if a user later decides that they want to get rid of one (or more) of the images they've previously selected? The only way to would be to re-select all of them again minus the one they no longer want to upload.
That's one way to provide some terrible user experience, so let's add a button that will allow them to do exactly that.
To create a "close" icon, I borrowed this helpful code from StackOverflow. But it's pretty boring as-is:
With a few modifications, this is what our code looks like:
export const CloseButton = ()=> {
return (
<>
<CloseButtonContainer/>
</>
);
}
const CloseButtonContainer = styled.button`
position: relative;
// reduce the height and width so it fits better in our components
height: 25px;
width: 25px;
display: flex;
flex-direction: column;
// align the "X" icon vertically
justify-content: center;
// align the "X" icon horizontally
align-items: center;
// set the background color to a light shade of grey
background: lightgrey;
// round off the corners
border-radius: 50%;
// remove the default border
border: none;
// this renders two straight vertical lines with a white background
&::before, &::after {
position: absolute;
content: '';
// set the width to half the width of the container
width: 50%;
height: 3px; /* cross thickness */
// change the color of the "X" icon to white
background-color: white;
}
&::before {
transform: rotate(45deg);
}
&::after {
transform: rotate(-45deg);
}
`
Which produces the following button:
Then, we add the above button to our <ImageContainer/>
:
//...
const Image = (props: ImageProps) => {
return (
<>
<ImageContainer>
+ <CloseButton/>
<img src={props.src} alt={props.alt}/>
</ImageContainer>
</>
);
};
This is what a single item in the preview container looks like afterwards.
All that's left to do is ensure the close icon is always in the top-right corner of the image.
To achieve this, we make the button absolute
-ly positioned and add the top
and right
CSS attributes.
Finally, we add a z-index
so it's always on top of the image.
const CloseButtonContainer = styled.div`
+ position: absolute;
+ z-index: 1;
+ right: 4px;
+ top: 4px;
// ...
`
So that the new image preview looks exactly like we want it to:
Now, the catch is that for this to work properly, the parent must always be relative
ly positioned. That's
not a problem here, since <ImageContainer/>
is relatively positioned already. However, it's
easy to be caught off guard by an absolute
element that doesn't respond properly to the
top
, right
, left
and bottom
CSS attributes.
Then, let's add a new prop for when the close button is clicked:
type ImageProps = {
src: string;
alt: string;
// We add a new `onClickCloseButton` prop on Image, to called whenever the close button is clicked
+ onClickCloseButton: (e: number)=> void
}
const Image = (props: ImageProps) => {
return (
<>
<ImageContainer>
+ <CloseButton onClick={props.onClickCloseButton}/>
<img src={props.src} alt={props.alt}/>
</ImageContainer>
</>
);
};
type ImagePreviewProps = {
images: Array<LocalFile>
// We then add a new prop to ImagePreview. The function should be called
// with the index of the image to be removed.
+ onRemoveImage: (e: number)=> void
}
const ImagePreview = (props: ImagePreviewProps)=> {
return (
<>
<ImagePreviewContainer>
{
props.images?.map((image, index) => (
<>
<Image
+ onClickCloseButton={() => props.onRemoveImage(index)}
src={image.src} alt={''}/>
</>
))
}
</ImagePreviewContainer>
</>
);
}
export default ImagePreview;
Finally, lets add some logic for actually removing the image from state:
function ImageUploader (){
const [selectedImages, setSelectedImages] = useState<>([]);
// ...
+ function removeImage(i: number){
+ const newSelectedImages = [...selectedImages];
+ newSelectedImages.splice(i, 1)
+ setSelectedImages(newSelectedImages);
+ }
return (
<div>
<ImagePreview
images={selectedImages}
+ onRemoveImage={(i)=> removeImage(i)}
/>
{/* ... */}
</div>
);
}
And, in use:
All that's left to do with our image uploader is to upload the selected images to a server.
First off, let's add an 'upload' button:
type ButtonsProps = {
+ onClickUpload: (images: LocalFile[])=> void
onSelectImage: (e: React.ChangeEvent<HTMLInputElement>)=> void
}
const Buttons = (props: ButtonsProps)=> {
{/*...*/}
return (
<ButtonsContainer>
{/*...*/}
<Button onClick={()=> buttonRef?.current?.click()}>
Select images
</Button>
+ <Button
+ onClick={props.onClickUpload}
+ style={{background: 'dodgerblue'}}>
+ Upload
+ </Button>
</ButtonsContainer>
);
}
When a user clicks the "Upload" button, we should upload the image and remove it from the list of selected files, should the upload succeed.
We are going to assume all images will upload successfully for now, so don't worry about error states.
When making a POST request, data in the request body has to be
encoded into one of three methods of encoding -
application/x-www-form-urlencoded
(the default), multpart/form-data
,
or text/plain
.
In order to send files, they have to be encoded as multpart/form-data
objects. Javascript
exposes this through the FormData API.
We need to create a FormData
object and append the file and
any other metadata we want to transmit.
function ImageUploader (){
// ...
const [selectedImages, setSelectedImages] = useState<Array<LocalFile>>([]);
+ async function upload(images: LocalFile[]){
+ const uploaded = [...uploadedImages];
+ // for each image
+ for (let i = 0; i < images.length; i++){
+ let localFile: LocalFile = images[i];
+ // create a FormData object,
+ const formData = new FormData();
+
+ const currentImage = (localFile.file as File);
+ // append the file
+ formData.append('file', currentImage);
+ // and the file name
+ formData.append('fileName', currentImage.name);
+
+ try {
+ // upload the file
+ await axios.post<UploadedFile>(UPLOAD_URL, formData);
+ }catch (e){
+ console.log("Could not upload image due to an error: ", e);
+ }
+
+ }
+ }
// ...
}
Once an image has been uploaded successfully, we should remove it from from the list of selected files.
//..
async function upload(images: LocalFile[]){
for (let i = 0; i < images.length; i++){
let localFile: LocalFile = images[i];
const formData = new FormData();
const currentImage = (localFile.file as File);
formData.append('file', currentImage);
formData.append('fileName', currentImage.name);
try {
const response = await axios.post<UploadedFile>(UPLOAD_URL, formData);
+ images.splice(i, 1);
+ setSelectedImages(images)
}catch (e){
console.log("Could not upload image due to an error: ", e);
}
}
}
//...
Intuitively, this code should run perfectly, but on closer inspection, there are two major problems:
We are calling the Array.splice
function on an array we are iterating.
The Array.splice
function modifies the original array, changing
its length in the process. Therefore, the images.length
value we used when initializing the for-loop is now obsolete.
In other words, once an item is removed at the end of the first iteration,
the array will be shorter than at the for-loop.
On the second iteration, the i
index we will be working with will be
"a step forward" so to speak.
There are two ways we can remedy this situation:
The first way we can resolve the issue is to iterate the array in reverse:
```diff-tsx:title=ImageUploader.tsx
//..
async function upload(images: LocalFile[]){
- for (let i = 0; i < images.length; i++){
+ for (let i = images.length -1; i >= 0 ; i--){
//..
}
}
//...
```
Since the value of `i` is not affected by changing the length of the array,
this will work perfectly. There's just one problem. Watch what happens when
the "Upload" button is clicked.
 <div>
<small>
Notice anything strange? The images are being removed in reverse order.
</small>
</div>
The images are removed in reverse order. This makes sense, since we're iterating
the array in reverse, doesn't it?
#### b) Decrementing the index
The second (and my personal favourite) way of sorting our iteration issues out
is to decrement the value of `i` after the `.splice()` method is called.
```diff-tsx:title=ImageUploader.tsx
//..
async function upload(images: LocalFile[]){
for (let i = 0; i < images.length; i++){
let localFile: LocalFile = images[i];
const formData = new FormData();
const currentImage = (localFile.file as File);
formData.append('file', currentImage);
formData.append('fileName', currentImage.name);
try {
+ console.log("Working with index: ", i)
const response = await axios.post<UploadedFile>(UPLOAD_URL, formData);
images.splice(i, 1);
setSelectedImages(images)
+ i--;
}catch (e){
console.log("Could not upload image due to an error: ", e);
}
}
}
//...
```
After selecting five items, here is the output of the above `console.log`
when we click on the "Upload" button:

What happens, essentially, is the value of `i` remains 0 after
every iteration. Therefore, the first item of the array
is uploaded and removed.
In action:

<div>
<small>
Note: One element remains after all the others have been uploaded. We'll cover
this bug in the next section.
</small>
</div>
We are calling the setSelectedImages()
function in a for-loop.
Always be careful when calling a setState()
function within a loop. The setState()
function doesn't work exactly like you'd expect. It doesn't immediately change the
value in state.
Instead, think of calling setState()
as a request to update the component, rather than
being invoked immediately. React may delay it, and update many components in a single pass.
There's no guarantee the component will be updated immediately. In practice, we may receive a previous value in
selectedImages
, leading to unexpected behaviour.
To fix the issue, we should clone the array before setting it to state:
//..
async function upload(images: LocalFile[]){
for (let i = 0; i < images.length; i++){
let localFile: LocalFile = images[i];
const formData = new FormData();
const currentImage = (localFile.file as File);
formData.append('file', currentImage);
formData.append('fileName', currentImage.name);
try {
- console.log("Working with index: ", i)
const response = await axios.post<UploadedFile>(UPLOAD_URL, formData);
images.splice(i, 1);
- setSelectedImages(images)
+ setSelectedImages([...images])
i--;
}catch (e){
console.log("Could not upload image due to an error: ", e);
}
}
}
//...
Now, the uploader should work like we expect:
For the final part of this tutorial, there should be some visual representation of the 'uploading' state so users aren't left wondering whether an image is uploading or not.
Let's not do anything too fancy for now, lest this blog post spriral out a managable length.
function ImageUploader(){
+ // since only one image is uploaded at a time, we can track its progress
+ const [uploadProgress, setUploadProgress] = useState(0);
+ // the index of the image being uploaded
+ const [uploadIndex, setUploadIndex] = useState(0);
+ // the total number of images being uploaded
+ const [totalCount, setTotalCount] = useState(0);
//...
async function upload(images: LocalFile[]){
// set the total number of items to be uploaded
+ setTotalCount(images.length);
// and the index of the image being uploaded + 1
+ let uploadCount = 1;
for (let i = 0; i < images.length; i++){
let localFile: LocalFile = images[i];
const formData = new FormData();
const currentImage = (localFile.file as File);
formData.append('fileName', currentImage.name);
try {
+ setUploadIndex(uploadCount++);
await axios.post(UPLOAD_URL, formData, {
+ onUploadProgress: (progressEvent)=> {
+ // calculate the upload progress as a percent
+ const uploadPercent = Math.round((progressEvent.loaded * 100) / progressEvent.total);
+ setUploadProgress(uploadPercent);
}
});
+ // once an upload succeeds, reset the progressbar to 0
+ setUploadProgress(0);
images.splice(i, 1);
setSelectedImages([...images]);
i--;
}catch (e){
console.log("Could not upload image due to an error: ", e);
}
}
}
return (
<div>
{
selectedImages?.length ? (
<>
<ImagePreviewContainer>
{
selectedImages?.map((image, index) => (
<>
<Image onClickCloseButton={() => removeImage(index)}
src={image.src} alt={''}/>
</>
))
}
</ImagePreviewContainer>
+ {
+ uploadIndex ? (
+ <div>
+ <p>Uploading: image {uploadIndex}/{totalCount} - {uploadProgress}%</p>
+ </div>
+ ) : <span/>
+ }
</>
): <p>No selected images</p>
}
<Buttons
onClickUpload={() => upload(selectedImages)}
onSelectImage={onSelectImage}/>
</div>
);
}
And, in action:
Marking the end of the first part of this tutorial.
Questions? Concerns? Suggestions? Just want to say hi? my inbox is always open. Don't be shy. 😄
In this tutorial, we covered the basics of how to build an image uploader component in React. We didn't get into the complicated stuff, but for the curious minds, we'll dig deeper into it in the second half of this guide. It gets even more interesting, trust me.
In the next part we will:
<Formik/>
Adios!
PS:
Copyright © 2022 Bradley K.