Skip to content

ReactJS useState hook – asynchronous behaviour

I am building a page to list products. So I’ve one input:file button to select multiple images and then I’m calling an API to upload that images on the server and displaying progress in UI with images. Here’s my code.

import axios from 'axios'
import React, { useState } from 'react'
import { nanoid } from 'nanoid'

const Demo = () => {
    const [images, setImages] = useState([])

    const handleImages = async (e) => {
        let newArr = [...images]
        for (let i = 0; i < e.target.files.length; i++) {
            newArr = [
                ...newArr,
                {
                    src: URL.createObjectURL(e.target.files[i]),
                    img: e.target.files[i],
                    uploaded: 0,
                    completed: false,
                    _id: nanoid(5),
                },
            ]
        }
        setImages(newArr)
        await uploadImages(newArr)
    }

    const uploadProgress = (progress, arr, idx) => {
        let { total, loaded } = progress
        let inPercentage = Math.ceil((loaded * 100) / total)
        let imgs = [...arr]
        imgs[idx]['uploaded'] = inPercentage
        setImages([...imgs])
    }

    const uploadImages = async (imageArr) => {
        for (let i = 0; i < imageArr.length; i++) {
            let formData = new FormData()
            formData.append('img', imageArr[i].img)

            let result = await axios.post(
                `http://localhost:3001/api/demo`,
                formData,
                {
                    onUploadProgress: (progress) =>
                        uploadProgress(progress, imageArr, i),
                }
            )

            if (result?.data) {
                let imgs = [...imageArr]
                imgs[i]['completed'] = true
                setImages([...imgs])
            }
        }
    }

    return (
        <div>
            <input
                type="file"
                multiple
                accept="image/*"
                onChange={handleImages}
            />
            <div>
                <div className="img-container" style={{ display: 'flex' }}>
                    {images.length ? (
                        <>
                            {images.map((img) => (
                                <div style={{ position: 'relative' }}>
                                    <img
                                        style={{
                                            width: '200px',
                                            height: 'auto',
                                            marginRight: '10px',
                                        }}
                                        src={img.src}
                                        alt="alt"
                                    />
                                    {!img.completed && (
                                        <div
                                            style={{
                                                background: 'rgba(0,0,0,0.3)',
                                                padding: '4px',
                                                position: 'absolute',
                                                top: '10px',
                                                left: '10px',
                                                color: 'white',
                                                borderRadius: '5px',
                                            }}
                                        >
                                            {img.uploaded}%
                                        </div>
                                    )}
                                </div>
                            ))}
                        </>
                    ) : null}
                </div>
            </div>
        </div>
    )
}

export default Demo

As the useState is asynchronous, I can’t directly pass that react state to my API handler. So now the issue is, suppose I’m selecting 3 images to upload, and before the execution of my “uploadImages” function completes, I try to select other images to upload, It’s not working as expected and I know the reason why it’s not working the way intend to. But don’t know the solution.

Issue: suppose, the user first tries to upload 3 images. the first instance of “uploadImages” will begin its execution with parameter newArr which would have 3 images and we’re setting it to react-state “images”. But now when the user tries to upload other images before letting the first ones finish, another instance of “uploadImages” will begin its execution and now in newArr parameter, it would have an array of newly added images and this method will try to set state “images”.

Now I don’t know how can I handle this. Preview

Answer

There are two problems.

  1. Each time uploadProgress is run, it’s using the images array that was passed to it by the uploadImages function. In other words, if you start uploading image A, you trigger an instance of uploadProgress running with imageArr = [A]. If you add image B, you trigger another separate instance of uploadProgress running with imageArr = [A,B]. Since you’re setting state using those separate imageArrs in uploadProgress, the images state swaps from being the first array to the second array and back. (You can see this if you log from inside uploadProgress.)
    const uploadProgress = (progress, arr, idx) => {
        let { total, loaded } = progress
        let inPercentage = Math.ceil((loaded * 100) / total)
        let imgs = [...arr]
        console.log(imgs.length) // will toggle between 1 and 2 
        imgs[idx]['uploaded'] = inPercentage
        setImages([...imgs])
    }

As others have said, you can solve this by using the functional setState pattern.

    const uploadProgress = (progress, idx) => {
        let { total, loaded } = progress
        let inPercentage = Math.ceil((loaded * 100) / total)
        setImages(arr => {
          let imgs = [...arr]
          imgs[idx]['uploaded'] = inPercentage
          return imgs
        })
    }
  1. Second, each time you trigger uploadImages it starts the upload for every image, cumulatively. That means if you upload image A, wait a second, then add image B, it’ll start uploading image A again and you’ll end up with two different upload of image A. If you then were to add image C, you’d get three copies of image A, two copies of image B, and one of image C. You can solve this by preventing upload of images that have progress value or add a new property that indicates the image upload process has been started already.
const uploadImages = async (imageArr) => {
  for (let i = 0; i < imageArr.length; i++) {
    if (imageArr[i].progress === 0) { // don't upload images that have already started