can't clean up setTimeout in useEffect unmount cleanup

I am trying to make kind of flash message displayer to display success, error, warning messages at the top for a certain duration.

I have made the use of useRef hook to store timeouts so that I can clear it incase component unmounts before timeout completion.

Everything works as expected except, if the component unmounts before timeout callback, it does not clear the timeout which indeed is trying to setState which gives

Warning: Can't perform a React state update on an unmounted component

import React, { useEffect, useRef, useState } from 'react'
import SuccessGreen from '../../assets/SuccessGreen.svg'
import Cross from '../../assets/Cancel.svg'
import WarningExclamation from '../../assets/WarningExclamation.svg'

const ICONS_MAP = {
    "warning": WarningExclamation,
    "success": SuccessGreen,
    "error": ""
}

export const FlashMessages = ({
    duration=5000,
    closeCallback,
    pauseOnHover=false,
    messageTheme='warning',
    typoGraphy={className: 'text_body'},
    firstIcon=true,
    ...props
}) => {
    const [isDisplayable, setIsDisplayable] = useState(true)
    const resumedAt = useRef(null)
    const remainingDuration = useRef(duration)    
    const countDownTimer = useRef(null)
    
    useEffect(() => {
        countDownTimer.current = resumeDuration()
        console.log(countDownTimer, "From mount")
        return () => {clearTimeout(countDownTimer.current)}
    }, [])

    const resumeDuration = () => {
        clearTimeout(countDownTimer.current)
        resumedAt.current = new Date()
        return setTimeout(() => forceCancel(), remainingDuration.current)
    }

    const pauseDuration = () => {
        if(pauseOnHover){
            clearTimeout(countDownTimer.current)
            remainingDuration.current = remainingDuration.current - (new Date() - resumedAt.current)
        }
    }
    
    const forceCancel = () => {
        console.log(countDownTimer, "From force")
        clearTimeout(countDownTimer.current);
        setIsDisplayable(false);
        closeCallback(null);
    }

    return isDisplayable ? (
        <div onMouseEnter={pauseDuration} onMouseLeave={resumeDuration}
            className={`flash_message_container ${messageTheme} ${typoGraphy.className}`} style={props.style}>
            {   firstIcon ? (<img src={ICONS_MAP[messageTheme]} style={{marginRight: 8, width: 20}} />) : null   }
            <div style={{marginRight: 8}}>{props.children}</div>
            <img src={Cross} onClick={forceCancel} style={{cursor: 'pointer', width: 20}}/>
        </div>
    ):null
}

I have tried to mimic the core functionality of this npm package https://github.com/danielsneijers/react-flash-message/blob/master/src/index.jsx but whith functional component.


Solution 1:

I think the problem is that when the mouseleave event happens, the timeout id returned by resumeDuration is not saved in countDownTimer.current, so the timeout isn't cleared in the cleanup function returned by useEffect.

You could modify resumeDuration to save the timeout id to countDownTimer.current instead of returning it:

countDownTimer.current = setTimeout(() => forceCancel(), remainingDuration.current)

and then, inside useEffect, just call resumeDuration, so the component would look like this:

import React, { useEffect, useRef, useState } from 'react'
import SuccessGreen from '../../assets/SuccessGreen.svg'
import Cross from '../../assets/Cancel.svg'
import WarningExclamation from '../../assets/WarningExclamation.svg'

const ICONS_MAP = {
    "warning": WarningExclamation,
    "success": SuccessGreen,
    "error": ""
}

export const FlashMessages = ({
    duration=5000,
    closeCallback,
    pauseOnHover=false,
    messageTheme='warning',
    typoGraphy={className: 'text_body'},
    firstIcon=true,
    ...props
}) => {
    const [isDisplayable, setIsDisplayable] = useState(true)
    const resumedAt = useRef(null)
    const remainingDuration = useRef(duration)
    const countDownTimer = useRef(null)

    useEffect(() => {
        resumeDuration()
        console.log(countDownTimer, "From mount")
        return () => {clearTimeout(countDownTimer.current)}
    }, [])

    const resumeDuration = () => {
        clearTimeout(countDownTimer.current)
        resumedAt.current = new Date()
        countDownTimer.current = setTimeout(() => forceCancel(), remainingDuration.current)
    }

    const pauseDuration = () => {
        if(pauseOnHover){
            clearTimeout(countDownTimer.current)
            remainingDuration.current = remainingDuration.current - (new Date() - resumedAt.current)
        }
    }

    const forceCancel = () => {
        console.log(countDownTimer, "From force")
        clearTimeout(countDownTimer.current);
        setIsDisplayable(false);
        closeCallback(null);
    }

    return isDisplayable ? (
        <div onMouseEnter={pauseDuration} onMouseLeave={resumeDuration}
            className={`flash_message_container ${messageTheme} ${typoGraphy.className}`} style={props.style}>
            {   firstIcon ? (<img src={ICONS_MAP[messageTheme]} style={{marginRight: 8, width: 20}} />) : null   }
            <div style={{marginRight: 8}}>{props.children}</div>
            <img src={Cross} onClick={forceCancel} style={{cursor: 'pointer', width: 20}}/>
        </div>
    ):null
}

and it would then mimic the logic from https://github.com/danielsneijers/react-flash-message/blob/master/src/index.jsx