Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Trying to use cleanup function in useEffect hook to cleanup img.onload

I recently built a React component (called ItemIndexItem) which displays images on my application. For example, I have a Search component which will show an index of filtered Items. Each Item displayed is an ItemIndexItem component. Clicking an ItemIndexItem sends you to a ItemShow page where the same ItemIndexItem is employed.

Search.jsx

render() {
  return (
    <ul>
      <li key={item.id}>
        <div>
          <Link to={`/items/${item.id}`}>
            <ItemIndexItem src={item.photos[0].photoUrl} />
            <p>${item.price}</p>
          </Link>
        </div>
      </li>
      ...more li's
    </ul>
  )
}

ItemIndexItem.jsx

import React, { useState, useEffect } from "react";

export default function ItemIndexItem(props) {
  const [imageIsReady, setImageIsReady] = useState(false);

  useEffect(() => {
    let img = new Image();
    img.src = props.src;

    img.onload = () => {
      setImageIsReady(true);
    };
  });

  if (!imageIsReady) return null;

  return (
    <div>
      <img src={props.src} />
    </div>
  );
}

The component is working exactly as desired, except for a memory leak error thrown in console:

Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.

in ItemIndexItem (created by ItemShow)

in div (created by ItemShow)

For reference, this is the code inside ItemShow where I render the ItemIndexItem:

ItemShow.jsx

return (
 ...
   <div>
     <ul>
       {this.props.item.photos.map(photo => {
         return (
           <li key={photo.photoUrl}>
             <div>
               <ItemIndexItem type='show' src={photo.photoUrl} />
             </div>
           </li>
         );
       })}
     </ul>
   </div>
 ...

I've tried utilizing a useEffect return function to set img to null:

return () => img = null;

This does nothing, however. Since I don't create a subscription, there's not one to delete. So I think the problem is in the asynchronous nature of .onload.

like image 932
owenshaupt Avatar asked Nov 08 '19 20:11

owenshaupt


2 Answers

You're setting the state of a component which isn't mounted anymore. You could use the useRef hook to determine if your component is still mounted or not, so for example:

function useIsMounted() {
  const isMounted = React.useRef(true);

  React.useEffect(() => {
    return () => {
      isMounted.current = false;
    };
  }, []);

  return isMounted;
}

and in your ItemIndexItem...

export default function ItemIndexItem(props) {
  const isMounted = useIsMounted();
  const [imageIsReady, setImageIsReady] = useState(false);

  ...
  img.onload = () => {
    if (isMounted.current) {
      setImageIsReady(true);
    }
  ...
}

As described in the React documentation of useRef.

useRef returns a mutable ref object whose .current property is initialized to the passed argument (initialValue). The returned object will persist for the full lifetime of the component.

This means that you can use it to create references to HTML elements, but you can also place other variables inside of that ref, such as a boolean. In the case of my 'useIsMounted' hook, it sets it as mounted upon initialization, and sets it as unmounted when unmounting.

like image 149
Alserda Avatar answered Oct 04 '22 18:10

Alserda


You're setting a state of a component no longer in the tree. I have a little hook utility to help with this scenario:

import { useCallback, useEffect, useRef } from 'react'

export const useIfMounted = () => {
  const isMounted = useRef(true)
  useEffect(
    () => () => {
      isMounted.current = false
    }, [])

  const ifMounted = useCallback(
    func => {
      if (isMounted.current && func) {
        func()
      } else {
        console.log('not mounted, not doing anything')
      }
    },[])

  return ifMounted
}

export default useIfMounted

Which you can then use like this:

    const ifMounted = useIfMounted()

    //other code

    img.onload = () => {
      ifMounted(() => setImageIsReady(true))
    }
like image 27
Jake Luby Avatar answered Oct 04 '22 19:10

Jake Luby