Search code examples
javascriptreactjsreact-hookscrop

How to avoid onCropComplete useCallback function gets stuck in infinite loop when updating state of other variable?


I'm trying to get react-easy-crop to work. The code successfully shows a button (Upload file) which triggers a screen to select a picture. Once the picture is taken, a new modal appears and this modal shows a round cropping overlay on top of the picture. The goals is to resize the picture and save it in croppedImage so I can store it to the backend.

The issue at hand is that 'onCropComplete' is called over and over again. I can see that by putting print statements. Removing the line

setCroppedAreaPixels(croppedAreaPixels);

resolves the issue although it is unclear why this happens.

Can anyone help me as to why this function keeps getting called? My code is attached below.

// The `Cropper` component is on the global object as `ReactEasyCrop`. Assign to `Cropper` so we can use it like a ES6 module
window.Cropper = window.ReactEasyCrop;
const { createRoot } = ReactDOM;
const { StrictMode, useEffect, useState, useRef, useCallback } = React;

// Mock `useAxios`
function useAxios() {}

// Mock `useParams`
function useParams() {
  return {
    id: 1,
  }
}

// Mock `getCroppedImg`
function getCroppedImg() {}

const UserDetail = () => {
  const { id } = useParams();
  const [res, setRes] = useState("");
  const [dateJoined, setDateJoined] = useState("");
  const [avatar, setAvatar] = useState("");
  const [modalState, setModalState] = useState(false);
  const api = useAxios();
  const inputRef = useRef(null);
  const [selectedImage, setSelectedImage] = useState(null);
  const [crop, setCrop] = useState({ x: 0, y: 0 });
  const [zoom, setZoom] = useState(1);
  const [croppedAreaPixels, setCroppedAreaPixels] = useState(null);
  const [croppedImage, setCroppedImage] = useState(null);

  const showHideClassName = modalState
    ? "modal display-block"
    : "modal display-none";

  useEffect(() => {
    console.log("useeffect!");
    const fetchData = () => {
      // make unrelated API call to backend
    };
    fetchData();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [id]);

  const handleFileChange = (event) => {
    // works - saves file to selected image and triggers cropping modal
    console.log(event.target.files[0].name);
    setSelectedImage(event.target.files[0]);
    showModal();
  };

  const handleFileUpload = (event) => {
    // function not yet called because problem in onCropComplete
    showCroppedImage();
    const sendData = () => {
      // API call to POST newly cropped image
    };
    sendData();
    hideModal();
  };

  const showModal = () => {
    setModalState(true);
  };

  const hideModal = () => {
    setModalState(false);
  };

  const onCropComplete = useCallback((croppedArea, croppedAreaPixels) => {
    //problematic function, is called infinitely
    setCroppedAreaPixels(croppedAreaPixels);
    console.log(croppedAreaPixels);
  }, []);

  const showCroppedImage = useCallback(() => {
    //code not working yet, first onCropComplete should work correctly
    try {
      // Converted async/await function to promise as Stack Snippets does not support async/await when transpiling with Babel as Babel version is too old. See: https://meta.stackoverflow.com/questions/386097/why-wont-this-snippet-with-async-await-work-here-on-stack-overflow-snippet-edit
      const croppedImage = getCroppedImg(
        selectedImage,
        croppedAreaPixels,
        0
      ).then(() => {
        console.log("donee", { croppedImage });
        setCroppedImage(croppedImage);
      });
    } catch (e) {
      console.error(e);
    }
  }, [selectedImage, croppedAreaPixels]);

  return (
    <div className="parent">
      <div className="userdetail">
        <div className="profilebanner">
          <div className="profilepicture">
            <img src={avatar} alt="" className="avatar" />
            <input
              ref={inputRef}
              onChange={handleFileChange}
              type="file"
              style={{ display: "none" }}
            />
            <button onClick={() => inputRef.current.click()}>
              Upload File
            </button>
            <div className={showHideClassName}>
              <section className="modal-main">
                <p align="center">Modal</p>
                <div className="cropper">
                  {selectedImage && (
                    <Cropper
                      image={URL.createObjectURL(selectedImage)}
                      crop={crop}
                      aspect={5 / 5}
                      zoom={zoom}
                      zoomSpeed={4}
                      maxZoom={3}
                      zoomWithScroll={true}
                      showGrid={true}
                      cropShape="round"
                      onCropChange={setCrop}
                      onCropComplete={onCropComplete}
                      onZoomChange={setZoom}
                    />
                  )}
                </div>
                <button onClick={handleFileUpload}>Confirm!</button>
                <button type="button" onClick={hideModal}>
                  Close
                </button>
              </section>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
};

const root = createRoot(document.getElementById("root"));
root.render(<StrictMode><UserDetail /></StrictMode>);
<script crossorigin defer src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin defer src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script crossorigin defer src="https://unpkg.com/[email protected]/tslib.js"></script>
<!-- tslib assigns its functions to the global object (e.g. `window.__assign`) however React Easy Crop expects it on the tslib object (e.g. `window.tslib.__assign`). This is hacky but allows React Easy Crop to find tslib functions on `window.tslib` -->
<script type="module">window.tslib = window</script>
<script crossorigin defer src="https://unpkg.com/[email protected]/umd/react-easy-crop.js"></script>
<div id="root"></div>


Solution

  • Problem

    Currently your code works like this:

    1. On file input change, set the selectedImage state to the file. This causes the component to re-render.
      const handleFileChange = (event) => {
        // works - saves file to selected image and triggers cropping modal
        console.log(event.target.files[0].name);
        setSelectedImage(event.target.files[0]);
        showModal();
      };
      
    2. During re-render, convert the file to a blob URL and pass it in the image prop of the Cropper component.
      return (
      // ...
        { selectedImage && (
          <Cropper
            image={URL.createObjectURL(selectedImage)}
          // ...
          />
        }
      );
      

    This is a problem because the following happens during render:

    1. URL.createObjectURL creates a new unique URL.
    2. Cropper re-renders because of the new URL passed to image.
    3. onCropComplete is called because a default crop is applied.
    4. UserDetail component re-renders because onCropComplete updates state. This returns us to step 1 and causes an infinite loop.

    The key thing to note is URL.createObjectURL is not deterministic. It produces a new result each time even with the same file, according to the algorithm in its specification. See step 9, which generates a UUID:

    To generate a new blob URL, run the following steps:

    1. Let result be the empty string.
    2. Append the string "blob:" to result.
    3. Let settings be the current settings object
    4. Let origin be settings’s origin.
    5. Let serialized be the ASCII serialization of origin.
    6. If serialized is "null", set it to an implementation-defined value.
    7. Append serialized to result.
    8. Append U+0024 SOLIDUS (/) to result.
    9. Generate a UUID [RFC4122] as a string and append it to result.
    10. Return result.

    Solution

    As a result, instead of calling URL.createObjectURL inside the image prop for the Cropper component, call it before you set the selectedImage state. Demo below:

    // The `Cropper` component is on the global object as `ReactEasyCrop`. Assign to `Cropper` so we can use it like a ES6 module
    window.Cropper = window.ReactEasyCrop;
    const { createRoot } = ReactDOM;
    const { StrictMode, useEffect, useState, useRef, useCallback } = React;
    
    // Mock `useAxios`
    function useAxios() {}
    
    // Mock `useParams`
    function useParams() {
      return {
        id: 1,
      }
    }
    
    // Mock `getCroppedImg`
    function getCroppedImg() {}
    
    const UserDetail = () => {
      const { id } = useParams();
      const [res, setRes] = useState("");
      const [dateJoined, setDateJoined] = useState("");
      const [avatar, setAvatar] = useState("");
      const [modalState, setModalState] = useState(false);
      const api = useAxios();
      const inputRef = useRef(null);
      const [selectedImage, setSelectedImage] = useState(null);
      const [crop, setCrop] = useState({ x: 0, y: 0 });
      const [zoom, setZoom] = useState(1);
      const [croppedAreaPixels, setCroppedAreaPixels] = useState(null);
      const [croppedImage, setCroppedImage] = useState(null);
    
      const showHideClassName = modalState
        ? "modal display-block"
        : "modal display-none";
    
      useEffect(() => {
        console.log("useeffect!");
        const fetchData = () => {
          // make unrelated API call to backend
        };
        fetchData();
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [id]);
    
      const handleFileChange = (event) => {
        // works - saves file to selected image and triggers cropping modal
        console.log(event.target.files[0].name);
        setSelectedImage(URL.createObjectURL(event.target.files[0]));
        showModal();
      };
    
      const handleFileUpload = (event) => {
        // function not yet called because problem in onCropComplete
        showCroppedImage();
        const sendData = () => {
          // API call to POST newly cropped image
        };
        sendData();
        hideModal();
      };
    
      const showModal = () => {
        setModalState(true);
      };
    
      const hideModal = () => {
        setModalState(false);
      };
    
      const onCropComplete = useCallback((croppedArea, croppedAreaPixels) => {
        //problematic function, is called infinitely
        setCroppedAreaPixels(croppedAreaPixels);
        console.log(croppedAreaPixels);
      }, []);
    
      const showCroppedImage = useCallback(() => {
        //code not working yet, first onCropComplete should work correctly
        try {
          // Converted async/await function to promise as Stack Snippets does not support async/await when transpiling with Babel as Babel version is too old. See: https://meta.stackoverflow.com/questions/386097/why-wont-this-snippet-with-async-await-work-here-on-stack-overflow-snippet-edit
          const croppedImage = getCroppedImg(
            selectedImage,
            croppedAreaPixels,
            0
          ).then(() => {
            console.log("donee", { croppedImage });
            setCroppedImage(croppedImage);
          });
        } catch (e) {
          console.error(e);
        }
      }, [selectedImage, croppedAreaPixels]);
    
      return (
        <div className="parent">
          <div className="userdetail">
            <div className="profilebanner">
              <div className="profilepicture">
                <img src={avatar} alt="" className="avatar" />
                <input
                  ref={inputRef}
                  onChange={handleFileChange}
                  type="file"
                  style={{ display: "none" }}
                />
                <button onClick={() => inputRef.current.click()}>
                  Upload File
                </button>
                <div className={showHideClassName}>
                  <section className="modal-main">
                    <p align="center">Modal</p>
                    <div className="cropper">
                      {selectedImage && (
                        <Cropper
                          image={selectedImage}
                          crop={crop}
                          aspect={5 / 5}
                          zoom={zoom}
                          zoomSpeed={4}
                          maxZoom={3}
                          zoomWithScroll={true}
                          showGrid={true}
                          cropShape="round"
                          onCropChange={setCrop}
                          onCropComplete={onCropComplete}
                          onZoomChange={setZoom}
                        />
                      )}
                    </div>
                    <button onClick={handleFileUpload}>Confirm!</button>
                    <button type="button" onClick={hideModal}>
                      Close
                    </button>
                  </section>
                </div>
              </div>
            </div>
          </div>
        </div>
      );
    };
    
    const root = createRoot(document.getElementById("root"));
    root.render(<StrictMode><UserDetail /></StrictMode>);
    <script crossorigin defer src="https://unpkg.com/react@18/umd/react.development.js"></script>
    <script crossorigin defer src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
    <script crossorigin defer src="https://unpkg.com/[email protected]/tslib.js"></script>
    <!-- tslib assigns its functions to the global object (e.g. `window.__assign`) however React Easy Crop expects it on the tslib object (e.g. `window.tslib.__assign`). This is hacky but allows React Easy Crop to find tslib functions on `window.tslib` -->
    <script type="module">window.tslib = window</script>
    <script crossorigin defer src="https://unpkg.com/[email protected]/umd/react-easy-crop.js"></script>
    <div id="root"></div>

    Additional notes

    It's also a good idea to release the object URL to aid memory management. From MDN:

    Memory management

    Each time you call createObjectURL(), a new object URL is created, even if you've already created one for the same object. Each of these must be released by calling URL.revokeObjectURL() when you no longer need them.

    Browsers will release object URLs automatically when the document is unloaded; however, for optimal performance and memory usage, if there are safe times when you can explicitly unload them, you should do so.

    I think you can do this inside the handleFileUpload function.