reactjsreact-hooksuppy

How to keep the image previews of images that I have already uploaded upon re-render of Uppy instance in a React app


I am able to upload images using Uppy on my react app: Uppy dashboard prompting user to upload

Upon selection and upload of an image, I see a preview of the image, along with options to remove it, add more, etc.: Upon selection and upload of the image

After clicking the "Continue" button, the state of reporting (from const [reporting, setReporting] = useState(false);) is set to true, which I think triggers a re-render of the DOM, including the disappearance of the UploadManager component, which contains the Uppy dashboard pictured above. Now, instead, a <ReportForm> component is rendered:

...
{reporting ? (
        <ReportForm assetReferences={files} exifData={exifData} />
      ) : (
        <>
          <Grid item style={{ marginTop: 20 }}>
            <UploadManager
              onUploadStarted={() => setUploadInProgress(true)}
              onUploadComplete={() => setUploadInProgress(false)}
...

enter image description here

When users click, "Back to Photographs" from the <ReportForm> component (see image above), it simply resets the state of reporting back to false. However, the Uppy Dashboard now shows its default, "Drop files here or browse files" again (see first image). I would like to see a preview of the images I just uploaded instead.

I am very new to React, but I can see that the uppyInstance is created in a useEffect hook, which seems to get called upon change of the reporting state. I suspect that this is what is "resetting" the uppy Dashboard (see code for the uppy-related component below):

UploadManager.jsx:

import React, { useEffect, useRef, useState } from 'react';
import { useIntl } from 'react-intl';
import { get } from 'lodash-es';
import Uppy from '@uppy/core';
import Tus from '@uppy/tus';
import Dashboard from '@uppy/react/lib/Dashboard';
import Skeleton from '@material-ui/lab/Skeleton';
import Cropper from './Cropper';

import '@uppy/core/dist/style.css';
import '@uppy/dashboard/dist/style.css';

const dashboardWidth = 600;
const dashboardHeight = 400;

export default function UploadManager({
  files,
  assetSubmissionId,
  onUploadStarted = Function.prototype,
  onUploadComplete = Function.prototype,
  setFiles,
  exifData,
  disabled = false,
  alreadyFiles = false,
}) {
  const intl = useIntl();
  const currentExifData = useRef();
  currentExifData.current = exifData;
  const [uppy, setUppy] = useState(null);
  const [cropper, setCropper] = useState({
    open: false,
    imgSrc: null,
  });

  /* Resolves closure / useEffect issue */
  // https://www.youtube.com/watch?v=eTDnfS2_WE4&feature=youtu.be
  const fileRef = useRef([]);
  fileRef.current = files;

  if (alreadyFiles) {
    // MAYBE I CAN DO SOMETHING HERE???
  }

  useEffect(() => {
    console.log('deleteMe useEffect entered');
    const uppyInstance = Uppy({
      meta: { type: 'Report sightings image upload' },
      restrictions: {
        allowedFileTypes: ['.jpg', '.jpeg', '.png'],
      },
      autoProceed: true,
      // browserBackButtonClose: true,
    });

    uppyInstance.use(Tus, {
      endpoint: `${__houston_url__}/api/v1/asset_groups/tus`,
      headers: {
        'x-tus-transaction-id': assetSubmissionId,
      },
    });

    uppyInstance.on('upload', onUploadStarted);

    uppyInstance.on('complete', uppyState => {
      const uploadObjects = get(uppyState, 'successful', []);
      const assetReferences = uploadObjects.map(o => ({
        path: o.name,
        transactionId: assetSubmissionId,
      }));

      onUploadComplete();
      // console.log('deleteMe fileRef.current is: ');
      // console.log(fileRef.current);
      // console.log('deleteMe ...fileRef.current is: ');
      // console.log(...fileRef.current);
      // // eslint-disable-next-line no-debugger
      // debugger;
      setFiles([...fileRef.current, ...assetReferences]);
    });

    uppyInstance.on('file-removed', (file, reason) => {
      if (reason === 'removed-by-user') {
        const newFiles = fileRef.current.filter(
          f => f.path !== file.name,
        );
        setFiles(newFiles);
      }
    });

    setUppy(uppyInstance);

    return () => {
      if (uppyInstance) uppyInstance.close();
    };
  }, []);

  return (
    <div
      style={{
        opacity: disabled ? 0.5 : 1,
        pointerEvents: disabled ? 'none' : undefined,
      }}
    >
      {cropper.open && (
        <Cropper
          imgSrc={cropper.imgSrc}
          onClose={() => setCropper({ open: false, imgSrc: null })}
          setCrop={croppedImage => {
            const currentFile = files.find(
              f => f.filePath === cropper.imgSrc,
            );
            const otherFiles = files.filter(
              f => f.filePath !== cropper.imgSrc,
            );
            setFiles([
              ...otherFiles,
              { ...currentFile, croppedImage },
            ]);
          }}
        />
      )}
      {uppy ? (
        <div style={{ marginBottom: 32, maxWidth: dashboardWidth }}>
          <Dashboard
            uppy={uppy}
            note={intl.formatMessage({ id: 'UPPY_IMAGE_NOTE' })}
            showLinkToFileUploadResult={false}
            showProgressDetails
            showRemoveButtonAfterComplete
            doneButtonHandler={null}
            height={dashboardHeight}
            locale={{
              strings: {
                dropHereOr: intl.formatMessage({
                  id: 'UPPY_DROP_IMAGES',
                }),
                browse: intl.formatMessage({ id: 'UPPY_BROWSE' }),
                uploading: intl.formatMessage({
                  id: 'UPPY_UPLOADING',
                }),
                complete: intl.formatMessage({ id: 'UPPY_COMPLETE' }),
                uploadFailed: intl.formatMessage({
                  id: 'UPPY_UPLOAD_FAILED',
                }),
                paused: intl.formatMessage({ id: 'UPPY_PAUSED' }),
                retry: intl.formatMessage({ id: 'UPPY_RETRY' }),
                cancel: intl.formatMessage({ id: 'UPPY_CANCEL' }),
                filesUploadedOfTotal: {
                  0: intl.formatMessage({
                    id: 'UPPY_ONE_FILE_PROGRESS',
                  }),
                  1: intl.formatMessage({
                    id: 'UPPY_MULTIPLE_FILES_PROGRESS',
                  }),
                },
                dataUploadedOfTotal: intl.formatMessage({
                  id: 'UPPY_DATA_UPLOADED',
                }),
                xTimeLeft: intl.formatMessage({
                  id: 'UPPY_TIME_LEFT',
                }),
                uploadXFiles: {
                  0: intl.formatMessage({
                    id: 'UPPY_UPLOAD_ONE_FILE',
                  }),
                  1: intl.formatMessage({
                    id: 'UPPY_UPLOAD_MULTIPLE_FILES',
                  }),
                },
                uploadXNewFiles: {
                  0: intl.formatMessage({
                    id: 'UPPY_PLUS_UPLOAD_ONE_FILE',
                  }),
                  1: intl.formatMessage({
                    id: 'UPPY_PLUS_UPLOAD_MULTIPLE_FILES',
                  }),
                },
              },
            }}
          />
        </div>
      ) : (
        <Skeleton
          variant="rect"
          style={{
            width: '100%',
            maxWidth: dashboardWidth,
            height: dashboardHeight,
          }}
        />
      )}
    </div>
  );
}

Here is the code for the parent component in which UploadManager lives:

import React, { useState, useMemo } from 'react';
import { FormattedMessage } from 'react-intl';
import { v4 as uuid } from 'uuid';

import Grid from '@material-ui/core/Grid';
import InfoIcon from '@material-ui/icons/InfoOutlined';

import UploadManager from '../../components/report/UploadManager';
import ReportSightingsPage from '../../components/report/ReportSightingsPage';
import Text from '../../components/Text';
import Link from '../../components/Link';
import Button from '../../components/Button';
import ReportForm from './ReportForm';
// import useAssetFiles from '../../hooks/useAssetFiles';

export default function ReportSighting({ authenticated }) {
  // console.log('deleteMe ReportSighting called');
  const assetSubmissionId = useMemo(uuid, []);
  const [uploadInProgress, setUploadInProgress] = useState(false);
  const [alreadyFiles, setAlreadyFiles] = useState(false);
  const [files, setFiles] = useState([]);
  const [exifData, setExifData] = useState([]);
  const [reporting, setReporting] = useState(false);
  const noImages = files.length === 0;
  // const {
  //   setFilesFromComponent,
  //   getFilesFromComponent,
  // } = useAssetFiles();

  const onBack = () => {
    window.scrollTo(0, 0);
    // setFilesFromComponent(files);
    // setFiles(files);
    if (files) setAlreadyFiles(true);
    setReporting(false);
  };

  let continueButtonText = 'CONTINUE';
  if (noImages) continueButtonText = 'CONTINUE_WITHOUT_PHOTOGRAPHS';
  if (uploadInProgress) continueButtonText = 'UPLOAD_IN_PROGRESS';

  return (
    <ReportSightingsPage
      titleId="REPORT_A_SIGHTING"
      authenticated={authenticated}
    >
      {reporting ? (
        <Button
          onClick={onBack}
          style={{ marginTop: 8, width: 'fit-content' }}
          display="back"
          id="BACK_TO_PHOTOS"
        />
      ) : null}
      {reporting ? (
        <ReportForm assetReferences={files} exifData={exifData} />
      ) : (
        <>
          <Grid item style={{ marginTop: 20 }}>
            <UploadManager
              onUploadStarted={() => setUploadInProgress(true)}
              onUploadComplete={() => setUploadInProgress(false)}
              assetSubmissionId={assetSubmissionId}
              exifData={exifData}
              setExifData={setExifData}
              files={
                files
                // getFilesFromComponent()
                // ? getFilesFromComponent()
                // : files
              }
              setFiles={setFiles}
              alreadyFiles={alreadyFiles}
            />
            <div
              style={{
                display: 'flex',
                alignItems: 'center',
                marginTop: 20,
              }}
            >
              <InfoIcon fontSize="small" style={{ marginRight: 4 }} />
              <Text variant="caption">
                <FormattedMessage id="PHOTO_OPTIMIZE_1" />
                <Link
                  external
                  href="https://docs.wildme.org/docs/researchers/photography_guidelines"
                >
                  <FormattedMessage id="PHOTO_OPTIMIZE_2" />
                </Link>
                <FormattedMessage id="PHOTO_OPTIMIZE_3" />
              </Text>
            </div>
          </Grid>
          <Grid item>
            <Button
              id={continueButtonText}
              display="primary"
              disabled={uploadInProgress}
              onClick={async () => {
                window.scrollTo(0, 0);
                setReporting(true);
              }}
              style={{ marginTop: 16 }}
            />
          </Grid>
        </>
      )}
    </ReportSightingsPage>
  );
}

I suspect that the problem is that useEffect is triggering a re-render of UploadManager, but

  1. I am not sure whether that's true
  2. I am looking for a good strategy for preventing said re-render. Should I somehow use disabled=reporting on the Upload Manager logic? Should I limit what fires off the useEffect hook? If so, how, specifically, might I do that? Can I blacklist things (e.g., reporting) from firing a useEffect?

Here is the branch of the repository I'm working from for more reference if that's needed. Creating an example on stackblitz proved non-trivial.

Many thanks in advance for any suggestions!


Solution

  • I am unable to run the repro you provided because it appears to require private API keys.

    However, inspecting the code, here's what appears to be happening:

    1. The <ReportSighting> component renders for the first time with reporting state as false. This causes the <UploadManager> component to render, and a new instance of Uppy to be created by the effect within that component and stored in this instance of the <UploadManager>'s state.
    2. The user uploads a file, and hits the "continue" button, which sets reporting state to true. This causes the <ReportSighting> component to re-render, and show the <ReportForm> component instead. The original <UploadManager> component is unmounted, and the things that were present in its state, notably, the instance of Uppy that had the user's uploaded file, are lost.
    3. The user hits the "Back To Photos" button, which sets reporting state back to false. A this causes a new instance of <UploadManager> to render, and the effect that creates an instance of Uppy re-runs. The instance that is created is new as well, and thus won't show what it contained the last time it was rendered.

    One way to solve this would be to lift up state that needs to remain the same as the <UploadManager> is mounted and unmounted into the parent <ReportSighting> component. So the <ReportSighting> component would be responsible for creating the Uppy instance, and pass that as a prop to the <UploadManager>.

    See also this part of the Uppy docs:

    Functional components are re-run on every render. This could lead to accidentally recreate a fresh Uppy instance every time, causing state to be reset and resources to be wasted.

    The @uppy/react package provides a hook useUppy() that can manage an Uppy instance’s lifetime for you. It will be created when your component is first rendered, and destroyed when your component unmounts.

    You could also re-factor your app in other ways if it feels like too much is happening in a single component. But the guiding principle would be: "don't create the uppy instance more than once in a single user flow".