javascriptreactjsevent-handlingreact-stateevent-propagation

Deeper understanding of React event bubbling / propagation and state management


I apologise in advance if this is a silly question. Although I have managed to get it to work, I would like to get a deeper understanding.

I am building a custom hamburger menu in react which closes whenever you click anywhere outside the unordered list or the hamburger icon itself.

I have seen answers here Detect click outside React component And I have followed it but I couldn't understand why it wasn't working.

Firstly when it was just the hamburger icon and no click outside the menu to close option, it worked perfectly. Then when I used the useRef hook to get a reference to the unordered list in order to only close the menu when the list is not clicked, it worked perfectly except for when I clicked the actual hamburger icon.

After a lot of amateur debugging I finally realised what was happening. First when I opened the menu the state showMenu changed to true, Then when I clicked the hamburger icon to close, The parent wrapper element was firing first instead of the hamburger menu which is strange as during the bubbling phase I would expect the inner most element to fire first.

So the parent element would close the menu changing the state, causing the components to re-render. Then when the event would reach the actual icon the handleClick would once again toggle the state to true giving the impression that the hamburger click isn't working.

I managed to fix this by using event.stopPropogation() on the parent element. But this seems very strange because I would not expect the parent element's click to fire first especially when Im using bubbling phase. The only thing I can think of is because it is a native dom addeventlistener event it is firing first before the synthetic event.

Below is the code for the Mobile navigation which has the hamburger The header component renders the normal Nav or the MobileNav based on screen width. I haven't put code for the higher order components to make it easier to go through, but I can provide all the code if needed:

//MobileNav.js
export default function MobileNav() {

    const [showMenu, setShowMenu] = useState(false);
    const ulRef = useRef();

    console.log('State when Mobilenav renders: ', showMenu);

    useEffect(() => {
        
        let handleMenuClick = (event) => {

            console.log('App Clicked!');

            if(ulRef.current && !ulRef.current.contains(event.target)){
                setShowMenu(false);
                event.stopPropagation();
            }
        }

        document.querySelector('#App').addEventListener('click', handleMenuClick);

        return () => {
            document.querySelector('#App').removeEventListener('click', handleMenuClick);
        }
    }, [])

    return (
        <StyledMobileNav>
            <PersonOutlineIcon />
            <MenuIcon showMenu={showMenu} setShowMenu={setShowMenu} />

            {
                (showMenu) &&
                    <ul ref={ulRef} style={{
                            backgroundColor: 'green',
                            opacity: '0.7',
                            position: 'absolute',
                            top: 0,
                            right: 0,
                            padding: '4em 1em 1em 1em',
                        }}
                    >

                        <MenuList/>
                    </ul>
            }

            
        </StyledMobileNav>
    )
}

//MenuIcon.js

/**
* By putting the normal span instead of the MenuLine component after > worked in order to hover all div's
*/
const MenuWrap = styled.div`
    width: 28px;
    position: relative;
    transform: ${(props) => props.showMenu ? `rotate(-180deg)` : `none` };
    transition: transform 0.2s ease;
    z-index: 2;

    &:hover > div{
        background-color: white;
    }
`;

const MenuLine = styled.div`
    width: 100%;
    height: 2px;
    position: relative;
    transition: transform 0.2s ease;
    background-color: ${(props) => props.showMenu ? 'white' : mainBlue};
    &:hover {
        background-color: white;
    }
`;

const TopLine = styled(MenuLine)`
    ${(props) => {
        let style = `margin-bottom: 7px;`;
        if(props.showMenu){
            style += `top: 9px; transform: rotate(45deg);`;
        }
        return style;
    }}
`;

const MidLine = styled(MenuLine)`
    ${(props) => {
        let style = `margin-bottom: 7px;`;
        if(props.showMenu){
            style += `opacity: 0;`;
        }
        return style;
    }}
`;

const BottomLine = styled(MenuLine)`
    ${props => {
        if(props.showMenu){
           return `bottom: 9px; transform: rotate(-45deg);`;
        }
    }}
`;

export default function MenuIcon({showMenu, setShowMenu}) {

    const handleMenuClick = (event) => {
        console.log('Menu Clicked!');
        console.log('State before change Icon: ', showMenu);
        setShowMenu(!showMenu);
    }

    return (
        <MenuWrap onClick={handleMenuClick} showMenu={showMenu}>
            <TopLine onClick={handleMenuClick} showMenu={showMenu}></TopLine>
            <MidLine onClick={handleMenuClick} showMenu={showMenu}></MidLine>
            <BottomLine onClick={handleMenuClick} showMenu={showMenu}></BottomLine>
        </MenuWrap>
    )
}

Reading this article https://dev.to/eladtzemach/event-capturing-and-bubbling-in-react-2ffg basically it states that events in react work basically the same way as DOM events

But for some reason event bubbling is not working properly See screenshots below which show how the state changes:

State during initial render

Clicking the icon opens the menu and the console to the side shows the order of events

When closing the menu, the parent event handler fires before the child despite being in default bubbling phase

Can anyone explain why this happens or what is going wrong?


Solution

  • This is a common issue with competing event listeners. It seems you've worked out that the problem is that the click out to close handling and the menu button click to close handling are both triggered at the same time and cancel each other out.

    Event listeners should be called in the order in which they are attached according to the DOM3 spec, however older browsers may not implement this spec (see this question: The Order of Multiple Event Listeners). In your case the click out listener (in the <MobileNav> component) is attached first (since you use addEventListener there, while the child uses the React onClick prop).

    Rather than relying on the order in which event listeners are added (which can get tricky), you should update your code so that either the triggers do not happen at the same time (which is the approach this answer outlines) or so that the logic within the handlers do not overlap.

    Solution:

    If you move the ref'd element up a level so that it contains both the menu button and the menu itself you can avoid the overlapping/competing events.

    This way the menu button is within the space where clicks are ignored so the outer click listener (the click out listener) won't be triggered when the menu button is clicked, but will be if the user clicks anywhere outside the menu or its button.

    For example:

    return (
        <StyledMobileNav>
            <PersonOutlineIcon />
            <div ref={menuRef}>
                <MenuIcon showMenu={showMenu} setShowMenu={setShowMenu} />
                { showMenu && (
                    <ul>
                        <MenuList/>
                    </ul>
                )}
            </div>
        </StyledMobileNav>
    )
    

    Then use menuRef as the one to check for clicks outside of.

    As an additional suggestion, try putting all the menu logic into a single component for better organization, for example:

    function Menu() {
        const [showMenu, setShowMenu] = React.useState(false);
    
        // click out handling here
        
        return (
            <div ref={menuRef}>
                <MenuIcon showMenu={showMenu} setShowMenu={setShowMenu} />
                { showMenu && (
                    <ul>
                        <MenuList/>
                    </ul>
                )}
            </div>
        )
    }