Search code examples
reactjsresizeconditional-rendering

React Switching Menu Styles based on Window Width


I have a navigation menu that works nicely in two different layouts. On a smaller screen (mobile) we have a dropdown that opens over the header. On bigger screens, the items are offered next to each other across the top. I want to ensure the display updates between the two on resize. At the moment, if the user resizes from mobile-width to more, the menu items just disappear, unless the dropdown is left open (i.e. the IsActive boolean is set to true).

I am working in React (JSX), so the previous approach of having a js file that switches between CSS classes doesn't work nicely. I've thus switched to conditional rendering. I've read I should avoid forcing a re-render. Is there another way, or do I have to bite the bullet in this scenario?

Many thanks in advance for any help and pointers on best practices and solutions.

My current code:

Navigation.jsx:

import React, {useEffect, useState, Suspense} from "react";
import { NavLink } from "react-router-dom";
import navStyle from "../styles/Navigation.module.scss";
import {useTranslation} from "react-i18next";

function getWindowSize() {
    const {innerWidth, innerHeight} = window;
    return {innerWidth, innerHeight};
  }

function HeaderComponent() {
   (…)

  const [isActive, setIsActive] = useState(false);
  const menuClick = () => {
    if(windowSize >= 750) {
        setIsActive(true);
    }
    // toggle
    setIsActive(current => !current);
  };

  const [windowSize, setWindowSize] = useState(getWindowSize());

  useEffect(() => {
    function handleWindowResize() {
      setWindowSize(getWindowSize());
    }

    window.addEventListener('resize', handleWindowResize);

    return () => {
      window.removeEventListener('resize', handleWindowResize);
    };
  }, []);

    return <header>
<div>

    <nav>
    (…)
        <div id={navStyle.menu}>
            <button id={navStyle.menulink} onClick={menuClick}>Menu</button>
            {(isActive && (
                <ul id={navStyle.navlinks}>
                <li><NavLink to="/" >Home</NavLink></li>
                <li><NavLink to="about" >About</NavLink></li>
                <li><NavLink to="examples" >Examples</NavLink></li>
                <li><NavLink to="reviews" >Reviews</NavLink></li>
                <li><NavLink to="contact" >Contact</NavLink></li>
            </ul>
            )}
            
        </div>
    </nav>
    (…)
</div>
</header>
}

function Navigation() {
  return (
    <Suspense fallback="loading">
            <div className="App">
                <HeaderComponent/>
            </div>
        </Suspense>
  );
}

export default Navigation;

Navigation.module.scss:

@use "../styles/mixin.scss" as mixin;

/* MENU & NAVLINKS*/
.menu {
    float: left;
    width: 100%;
    margin: 0;
    font-weight: 400;
}
#menulink {
     background-color: mixin.$primary-color;
     margin: 0;
     color: mixin.$secondary-color;
     text-align: center;
     & a {
        color: mixin.$secondary-color;
        font-size: 1em;
        text-transform: uppercase;
        font-weight: 200;
        text-decoration: none;
        font-family: source-sans-pro;
        font-style: normal;
    } 
    @include mixin.non-mobile {
        display: none;
    }
}
  #navlinks {
    width: 100%;
    list-style-type: none;
    padding: 0;
    margin: 0;
    text-align: center;
    background-color: mixin.$black;
    background-color: rgba(0, 26, 51, 0.65);
    position: absolute;
    font-weight: 400;
    & a {
         display: block;
         padding: 10px 0;
         color: mixin.$secondary-color;
         font-weight: 400;
         text-decoration: none;
         text-transform: uppercase;
         &:hover, &:active, &:focus, &.thispage {
             color: mixin.$secondary-highlight;
            }
        }
        @include mixin.non-mobile {
            max-width: 1000px;
            position: static;
            &.collapsed {
                opacity: 1;
            }
            & a {
                width: 15%;
                padding-top: 15 0;
                margin-bottom: 20px;
                float: left;
                font-weight: 700;
                &:hover, &:active, &:focus, &.thispage {
                    font-size: 18px;
                }
            }
        }
    }
    (…)

mixin.scss

$primary-color: #001A33;
$primary-highlight: #002a54;
$secondary-color: #fffeea;
$secondary-highlight: #fffbad;
$black: #000000;
  

@mixin non-mobile {
    @media (min-width: 750px) {
        @content
    }
}
}

Solution

  • You are correct that unnecessary re-renders should be avoided. Storing the windowWidth in state every time it changes will cause many re-renders and is not very performant.

    A better solution would be to only store the boolean state mobile/non-mobile, as this will only change when you cross the width threshold.

    You could adapt your code to look something more like this:

    function isMobileWidth() {
        return window.innerWidth < 750;
    }
    
    function HeaderComponent() {
      (…)
    
      const [isMobile, setIsMobile] = useState(isMobileWidth());
    
      useEffect(() => {
        function handleWindowResize() {
          setIsMobile(isMobileWidth());
        }
    
        window.addEventListener('resize', handleWindowResize);
    
        return () => {
          window.removeEventListener('resize', handleWindowResize);
        };
      }, []);
    
      (...)
    }
    

    This approach still makes use of the window resize event, but will only cause a re-render when isMobile changes.

    An even better way of doing it would be to make use of the Window.matchMedia() function. This takes in a media query as an argument (as you would use within css) and will only fire an event when the outcome of the media query changes. Unlike the window resize event which is called repeatedly when resizing.

    You would write that as so:

    const mobileMediaQuery = '(max-width: 750px)'   
    
    function HeaderComponent() {
      (…)
    
      const [isMobile, setIsMobile] = useState(window.matchMedia(mobileMediaQuery).matches);
    
      useEffect(() => {
        
        const query = window.matchMedia(mobileMediaQuery);
    
        function handleQueryChange(queryEvent) {
          
          /* The matches property will be true if the window width is below the mobile size. */
          setIsMobile(queryEvent.matches);
        }
    
        query.addEventListener('change', handleQueryChange);
    
        return () => {
          query.removeEventListener('change', handleQueryChange);
        };
      }, []);
    
      (...)
    }