1

I've the following codes:

http-common.ts

import axios from 'axios';

export default axios.create({
  baseURL: window.location.origin,
  headers: {
    'Content-type': 'application/json',
  },
});

types.ts

enum UploadStatus {
  NONE,
  UPLOADING,
  DONE,
}

export default UploadStatus;

UploadForm.tsx

import * as React from 'react';

import http from './http-common';
import UploadStatus from './types';

type UploadItem = {
  file: File,
  progress: number,
  uploadSuccess: boolean | undefined,
  error: string,
};

const sendUpload = (file: File, onUploadProgress: (progressEvent: any) => void) => {
  const formData = new FormData();
  formData.append('file', file);

  return http.post('/api/uploadFile', formData, {
    headers: {
      'Content-Type': 'multipart/form-data',
    },
    onUploadProgress,
  });
};

const UploadForm = () => {
  const [fileSelection, setSelection] = useState<UploadItem[]>([]);
  const [currUploadStatus, setUploadStatus] = useState<UploadStatus>(UploadStatus.NONE);

  useEffect(() => {
    if (currUploadStatus === UploadStatus.UPLOADING) {
      const promises: Promise<void>[] = [];

      // Problem code to be discussed

      Promise.allSettled(promises)
        .then(() => setUploadStatus(UploadStatus.DONE));
    }
  }, [currUploadStatus]);

  const handleFileSelectChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    if (event.target.files !== null) {
      let newSelection: UploadItem[] = Array.from(event.target.files).map((currFile) => ({
        file: currFile,
        progress: 0,
        uploadSuccess: undefined,
        error: '',
        isChecked: false,
      }));

      if (currUploadStatus === UploadStatus.NONE) {
        newSelection = newSelection.reduce((currSelection, newSelItem) => {
          if (
            currSelection.every((currSelItem) => currSelItem.file.name !== newSelItem.file.name)
          ) {
            currSelection.push(newSelItem);
          }
          return currSelection;
        }, [...fileSelection]);
      }

      setSelection(newSelection);
    }
  };

  // Rest of code
}

I'm trying to upload files from the client to the server. The second parameter in sendUpload, onUploadProgress, is a function returning the current file upload progress to let me render a progress bar on-screen.

In my first attempt, useEffect is implemented as follows:

  useEffect(() => {
    if (currUploadStatus === UploadStatus.UPLOADING) {
      const promises: Promise<void>[] = [];

      fileSelection.forEach((currUploadItem) => {
        const promise: Promise<void> = sendUpload(
          currUploadItem.file,
          (event: any) => {
            setSelection(() => fileSelection.map((currMapItem) => (
              currMapItem.file.name === currUploadItem.file.name
                ? {
                  ...currMapItem,
                  progress: Math.round((100 * event.loaded) / event.total),
                }
                : currMapItem
            )));
          },
        )
          .then(({ data }) => {
            setSelection(() => fileSelection.map((currMapItem) => (
              currMapItem.file.name === currUploadItem.file.name
                ? {
                  ...currMapItem,
                  uploadSuccess: data.uploadSuccess,
                }
                : currMapItem
            )));
          })
          .catch((error) => {
            setSelection(() => fileSelection.map((currMapItem) => (
              currMapItem.file.name === currUploadItem.file.name
                ? {
                  ...currMapItem,
                  error,
                  uploadSuccess: false,
                }
                : currMapItem
            )));
          });

        promises.push(promise);
      });

      Promise.allSettled(promises)
        .then(() => setUploadStatus(UploadStatus.DONE));
    }
  }, [currUploadStatus]);

In the above useEffect code, I observed that when the file is being uploaded (i.e. the sendUpload(currUploadItem.file, ...) portion), the progress is being properly updated into the fileSelection state. When I throttle my browser, I can see my progress bars correctly filling up over time.

However, once the promise resolves and I get to the .then(...) portion of the code, the progress value goes back to 0. In the then(...) code segment, uploadSuccess is correctly updated to true, and this value is successfully saved to state and stays that way. In other words, I can see upload success messages on the screen, but my progress bar has reset to 0% after reaching 100%.

In my second attempt, I changed the code to as follows:

  useEffect(() => {
    if (currUploadStatus === UploadStatus.UPLOADING) {
      const promises: Promise<void>[] = [];

      for (const [idx, item] of fileSelection.entries()) {
        const promise: Promise<void> = sendUpload(
          item.file,
          (event: any) => {
            const updatedSelection = [...fileSelection];
            updatedSelection[idx].progress = Math.round((100 * event.loaded) / event.total);
            setSelection(updatedSelection);
          },
        )
          .then(({ data }) => {
            const updatedSelection = [...fileSelection];
            updatedSelection[idx].uploadSuccess = data.uploadSuccess;
            setSelection(updatedSelection);
          })
          .catch((error) => {
            const updatedSelection = [...fileSelection];
            updatedSelection[idx] = {
              ...updatedSelection[idx],
              error,
              uploadSuccess: false,
            };
            setSelection(updatedSelection);
          });

        promises.push(promise);
      }

      Promise.allSettled(promises)
        .then(() => setUploadStatus(UploadStatus.DONE));
    }
  }, [currUploadStatus]);

In this second version, the code works perfectly. sendUpload(currUploadItem.file, ...) updates my progress bars correctly. When it reaches 100%, the upload succeeds and the promise resolves, and the rendered progress bars stays at 100%. then(...) completes, and uploadSuccess is updated to true. My success messages appear on screen, and the progress bars stay full, which is the correct behaviour.

Why did the first version of code fail but the second succeed? It seems to me that both versions are doing the exact same thing:

  1. Iterate through each item in fileSelection.
  2. For each item, upload the file to server via an axios promise, and get the upload progress in realtime
  3. When axios updates the upload progress, create a new array. Insert the items from the old array into this new one. For the array item whose file is being sent, update its progress value in realtime. Set state.
  4. When the upload is done, progress is at 100. The promise now resolves and executes .then(...).
  5. Create a new array. Insert the items from the old array into this new one. For the array item whose file is being sent, progress should still be at 100. Update uploadSuccess to the boolean value sent from the server. Set state.

I had thought both versions of code are doing the same things described above. Yet for some reason, the first version saved progress 100 to state at step 4 but it went back to 0 at step 5. The second version saved progress 100 to state at step 4, but at step 5 it remained at 100 as it should.

What happened?

thegreatjedi
  • 2,788
  • 4
  • 28
  • 49
  • 1
    paste your code in here https://jshint.com/ add /* jshint esversion: 6 */ at the top and see what I see. – Mark Schultheiss May 13 '21 at 21:49
  • just curious, why do you pass your `currUploadStatus` state to the `useEffect` dependancy array? I mean shouldn't this behave somewhat along these lines: `useEffect() - > .then().setState() - > rerender & update currUploadStatus which triggers useEffect() again fails if statement and rerenders the component again` or am I missing something? – zergski May 13 '21 at 22:12
  • 1
    @zergski `currUploadStatus` is an enum type with three possible values: none, uploading or done. None is the initial value. When the form is submitted, it changes to Uploading. The code inside `useEffect` should execute for this change only. When all promises are settled, it'll change to Done. And if the user selects a new set of files, it'll change back to None for some status texts on the screen to update accordingly. – thegreatjedi May 14 '21 at 00:52
  • yes but because `currUploadStatus` is passed to the dependancy array. when promises are fullfilled and it changes to DONE, doesn't it trigger the useEffect hook again, rerendering the component? – zergski May 14 '21 at 07:08
  • I'm not trying to correct you or anything, just what I've been taught so more of an educational question for me =) – zergski May 14 '21 at 07:17
  • 1
    @zergski yes, useEffect will trigger again, but there's a `if (currUploadStatus === UploadStatus.UPLOADING)` so the internal codes will only run when `currUploadStatus` changes to Uploading specifically. All other changes are detected by useEffect but does nothing. – thegreatjedi May 14 '21 at 09:21

1 Answers1

1

Your second code copies the array, but it still contains the same objects, one of which is then mutated in the line

updatedSelection[idx].progress = Math.round((100 * event.loaded) / event.total);

Your first code properly clones the objects as well by using spread syntax. However, the problem is that it takes to old fileSelection value from the time of the effect execution as its starting point, again and again, in each event. Notice that if you upload multiple files at once, it's even worse.

The reason is the closure over stale constant, as discussed in React - useState - why setTimeout function does not have latest state value?, How To Solve The React Hook Closure Issue?, How to deal with stale state values inside of a useEffect closure? or this blog article.

To fix this, use the callback version of setSelection:

  useEffect(() => {
    if (currUploadStatus === UploadStatus.UPLOADING) {
      const promises = fileSelection.map(uploadItem =>
        sendUpload(
          uploadItem.file,
          (event: any) => {
            setSelection(currSelection =>
//                       ^^^^^^^^^^^^^
              currSelection.map(item => item.file.name === uploadItem.file.name
                ? {
                  ...item,
                  progress: Math.round((100 * event.loaded) / event.total),
                }
                : item
              )
            );
          },
        )
          .then(({data}) => ({
            uploadSuccess: data.uploadSuccess
          }, error => ({
            error,
            uploadSuccess: false
          })
          .then(result => {
            setSelection(currSelection =>
//                       ^^^^^^^^^^^^^
              currSelection.map(item => item.file.name === uploadItem.file.name
                ? {
                  ...item,
                  ...result,
                }
                : item
              )
            );
          })
      );

      Promise.all(promises)
        .then(() => setUploadStatus(UploadStatus.DONE));
    }
  }, [currUploadStatus]);
Bergi
  • 630,263
  • 148
  • 957
  • 1,375