Search code examples
reactjsreact-hooksnext.jsresizechakra-ui

Using React change element behavior in UI based on screen width


I'll write some information about my project.

Framework: NextJs, below version 13 UI Framework: ChakraUI (Using predefined components, mostly Flex)

The idea is as following: I have tested and determined what screen sizes should require element behavior changes on the UI and decided to write code that will facilitate that functionality.

Doing things like, I'll avoid writing @media logic for every page/component, since I already know at which screen sizes changes will happen.

This is pretty vague, so I'll add some context:

    if (typeof window === "undefined") return 6;
    const windowWidth: number = window.innerWidth;

    if (windowWidth <= 600) return 2;
    else if (windowWidth <= 900) return 3;
    else if (windowWidth <= 1200) return 4;
    else if (windowWidth <= 1400) return 5;

    return 6;

As you can see, basically I've decided that for some breakpoint widths, a number will be returned. Using this I can influence, for instance, how many items are displayed, in a row, on the screen, or what the width of an element should be etc.

Example of usage for displaying how many items should be in a line:

width={${Math.floor(100 / displayItemsCount)}%}

Or here is an example of influencing the width of an item:

width={displayItemsCount <= 2 ? '90%' : displayItemsCount <= 4 ? '70%' : '50%'}

Note: To avoid confusion, displayItemsCount is the value that the above block of code returns.

Disclaimer: Since doing things like I imagined has caused me to do a lot of testing, research and has caused me issues, I'm also open for suggestions that are of the type: "This thinking is wrong, a better solution would be ...".

I know there a billion answers here regarding this issue, but I think they don't cover everything that I need here. Some of the code is from my SO research as well.

I'll write here some groundwork so that it's clear what's currently being used.

Firstly, I wanted a debounce present, so that the code doesn't get called on every pixel change. This is mostly for when the user is resizing the screen while using the Inspect Element tool.

function debounce(fn: Function, ms: number) {
    let timeoutId: ReturnType<typeof setTimeout>
    return function (this: any, ...args: any[]) {
        clearTimeout(timeoutId);
        timeoutId = setTimeout(() => fn.apply(this, args), ms);
    }
};

function determineDisplayItemsCount() {
    if (typeof window === "undefined") return 6;
    const windowWidth: number = window.innerWidth;

    if (windowWidth <= 600) return 2;
    else if (windowWidth <= 900) return 3;
    else if (windowWidth <= 1200) return 4;
    else if (windowWidth <= 1400) return 5;

    return 6;
};

Debounce calls the code whenever some amount time has passed after the screen was resized, while determineDisplayItemsCount returns a value based on the current screen size.

Disregard the naming of the determineDisplayItemsCount, this is a remnant of some old use-case, so it will be changed.

The second thing that I wanted, is that this code is present in a hook, so that it is easy to use.

function useDisplayItemsCount() {

    const [displayItemsCount, setDisplayItemsCount] = useState<number>(determineDisplayItemsCount());

    useEffect(() => {
        const debounceHandleResize = debounce(
            function handleResize() {
                let count: number = determineDisplayItemsCount();
                if (count != displayItemsCount)
                    setDisplayItemsCount(count);
            }, 250);
        window.addEventListener('resize', debounceHandleResize)

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

As you can see, in the useEffect the handleResize gets called whenever the screen changes width, but after an additional 250ms passes by. I also added if the new count is the same as the current displayItemsCount, then the state won't change.

The result is that I can go into any page/component now and go something like: const displayItemsCount = useDisplayItemsCount();. Then use that value for whatever I need.

Issues

  • First issue

When useDisplayItemsCount gets called, the initial value of displayItemsCount will be set based on the determineDisplayItemsCount function. This causes a hydration error, since I have to use window and the component wasn't mounted yet. My thinking was that if I had the following line in determineDisplayItemsCount: if (typeof window === "undefined") return 6;, I'd avoid the hydration error, but nope it's still there.

  • Second issue

So I decided heck, let's just have it so the initial value is 6: const [displayItemsCount, setDisplayItemsCount] = useState<number>(6);, but that results in the fact that when the page is loaded from a phone, aka smaller screen, useDisplayItemsCount returns 6 at the start. The useEffect inside the hook gets called only after the user scrolls a little bit on the page, or interacts with something.

I guess my question is, how to make this code work correctly, without causing a hydration error?

As stated, a great answer would also be an alternative approach.

Note: Probably worth stating that using Grid doesn't really solve my problems, since I want to avoid writing @media in all cases.


Solution

  • With CSS grid, you actually don't need to have a single media query to create grid columns that are fully responsive. Using the minmax function, with auto-fit or auto-fill you can set a minimum and maximum size for your grid items. The number of columns will automatically adjust to fit or fill according to the space available.

    Run the code snippet below and then use the full screen link to see what I mean.

    /*
    With CSS Grid, you can create a fully responsive grid in two declarations on the parent.  
    */
    
    .container {
      display: grid;
      grid-gap: .75rem;
      grid-template-columns: repeat(auto-fit, minmax(175px, 1fr));
    }
    
    .item {
      aspect-ratio: 1;
      background-color: #ccc;
      border: 1px solid;
      display: grid;
      font: bold 2rem sans-serif;
      place-content: center;
      width: 100%;
    }
    <div class="container">
      <div class="item">1</div>
      <div class="item">2</div>
      <div class="item">3</div>
      <div class="item">4</div>
      <div class="item">5</div>
      <div class="item">6</div>
      <div class="item">7</div>
      <div class="item">8</div>
      <div class="item">9</div>
    </div>