[logo] a small computer

Building an image uploader with React

building an image uploader with react

Bradley Kofi
Published 01/10/2021.
18 minute read
logo of undefined

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.

Feature set

An (almost complete) list of features an image uploader should have are:

  • A loading state for every individual image being uploaded.
  • A preview of all the selected images before they are uploaded.
  • The ability to remove images from the list of those to be uploaded.
  • The ability to embed the image uploader in a form.

In saying this, I've come across two great alternatives if you're not for building an image uploader from scratch.

  • Uppy: a really great library if you're using Vanilla JS, but it's super hard to configure and doesn't work so well with React Hooks. I struggled with it for a while then gave up.
  • react-images-uploader.

Dependencies

A list of dependencies we are going to need for this project are:

  • styled-components - for styling
  • axios - for network requests

Building an image picker component

We start off with a simple component that allows us to select files from our computer. In React-land, that looks like so:

ImageUploader.tsx
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:

default html input buttons

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:

Button.tsx

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:

select images button

Then, lets simulate a click event on the default <input/> when our button is clicked:

ImageUploader.tsx
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.

ImageUploader.tsx

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:

ImageUploader.tsx
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:

ImageUploader.tsx
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 LocalFiles is saved to state by calling the setSelectedImages() method.

Adding previews to our image uploader

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.

ImagePreview.tsx
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:

ImagePreview.tsx
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 LocalFiles. It then renders the individual images in the Image component we created previously.

And, in use:

ImageUploader.tsx

function ImageUploader (){

    //...

    return (
        <div>
 +          <ImagePreview images={selectedImages}/>
    
            <Buttons onSelectImage={onSelectImage}/>
        </div>
    );
}

Here's what it looks like so far:

initial preview

PS: My screen recorder doesn't capture the file picker UI.

Deleting individual images from the preview section

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:

x icon

With a few modifications, this is what our code looks like:

Button.tsx
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:

new x icon

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.

prevew image with close button

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:

properly formatted image preview

Now, the catch is that for this to work properly, the parent must always be relatively 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:

ImagePreview.tsx

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:

ImageUploader.tsx
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:

remove icon

Uploading the selected images

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:

ImageUploader.tsx

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.

ImageUploader.tsx

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);
+            }
+
+        }
+    }
  
    // ...

}

Removing uploaded images from the list of selected files

Once an image has been uploaded successfully, we should remove it from from the list of selected files.

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 {
                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);
            }


        }
    }   
    //...
Note: This code has bugs. It won't run as-is.

Intuitively, this code should run perfectly, but on closer inspection, there are two major problems:

  1. 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:

    a) Iterating the array in reverse

    The first way we can resolve the issue is to iterate the array in reverse:

    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.

    Notice anything strange? The images are being removed in reverse order.

    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.

    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:

    image uploader react console log

    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:

    Note: One element remains after all the others have been uploaded. We'll cover this bug in the next section.
  2. 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:

    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)
    +               setSelectedImages([...images])
                    i--;
                }catch (e){
                    console.log("Could not upload image due to an error: ", e);
                }
    
    
            }
        }   
        //...

    Now, the uploader should work like we expect:

Showing an uploading state

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.

ImageUploader.tsx
    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. 😄

Conclusion

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:

  • Set a loading state to individual images
  • Upload the images to a hosting service like Imagekit/Amazon S3... etc.
  • Display uploaded images
  • Save uploaded images locally
  • Embed the image uploader in a form using <Formik/>

Adios!

PS:

  • If you're interested in offering me a job, contract, consultation opportunity, my inbox is also open to you. Thanks!

Copyright © 2021 The Kenyan Dev