skip to Main Content

I have this sticky navbar in my NextJS 13 application. It’s a client component that I want to hide whenever the user has scrolled a certain amount of pixels.

"use client";

import React, { useEffect, useState } from "react";
import Logo from "./logo";
import Navbar from "./navbar";

const Header: React.FC = () => {
  const [isVisible, setIsVisible] = useState(true);

  const handleScroll = () => {
    const scroll = window.scrollY;
    console.log(scroll, isVisible);
    const shouldBeVisible = scroll <= 40;
    setIsVisible(shouldBeVisible);
  };

  useEffect(() => {
    window.addEventListener("scroll", handleScroll);
    return () => window.removeEventListener("scroll", handleScroll);
  }, []);

  return (
    <div className="sticky top-0 z-10 ">
      {isVisible && (
        <div
          className={`flex h-16 w-full items-center justify-between bg-white px-10 md:h-20 lg:px-32 `}
        >
          <Logo className="h-auto w-40 py-3 md:h-full md:w-auto" />
          <Navbar />
        </div>
      )}
    </div>
  );
};

export default Header;

Something is going on under the hood, which causes some weird behaviour.
I have chosen that if I reach scrollY == 40, then I should not render the navbar. However when I scroll to a certain point and stop scrolling, I get strange console output, coming from handleScroll. The navbar starts blinking, and the console output shows what looks like two different scrollY positions:

83 true
3 true
83 true
3 true
83 true
3 true

It seems like there is something I don’t know about useEffect, or perhaps about attaching event listeners to components. It looks as if two different Header components are trying to render – one that has a Navbar, and one that does not.

Furthermore, when I start scrolling from scrollY == 0, it’s as if the scrollY position gets reset to 0:

0 true
2 true
7 true
9 true
11 true
18 true
20 true
22 true
27 true
30 true
32 true
41 true
6 true   // at this point, I have scrolled more than 41 pixels

2

Answers


  1. Chosen as BEST ANSWER

    Instead of creating a complicated solution, I have created a simple solution. I now hide my component by making it transparent, rather than remove it from the DOM entirely. Here is the working code, using the opacity CSS property:

    return (
      <div className={`sticky top-0 z-10 transition-opacity duration-200 ${isVisible ? 'opacity-100' : 'opacity-0'}`}>
        <div
          className={`flex h-16 w-full items-center justify-between bg-white px-10 md:h-20 lg:px-32 `}
        >
          <Logo className="h-auto w-40 py-3 md:h-full md:w-auto" />
          <Navbar />
        </div>
      </div>
    );
    

  2. This doesn’t fix your issue but it’s something else to consider first. Your handleScroll function is being called every time you scroll, right?

    And inside this function, you’re calling setState, setIsVisible(shouldBeVisible);. At first glance this should work, but in terms of performance, if your component and its children are not too large it might not be a big issue. But, with your code, every time you scroll, it will re-render your component, even if your isVisible state is unchanged. So, to optimize this, you could add:

    const handleScroll = () => {
      const scroll = window.scrollY;
      console.log(scroll, isVisible);
       
      const shouldBeVisible = scroll <= 40;
      if (shouldBeVisible === isVisible) return;
      setIsVisible(shouldBeVisible);
    };
    

    As I said, it only makes a big difference if your component and its children are large, but still – it doesn’t hurt to add it. And now to what I think is the problem in terms of it not working, I think the problem lies in that, instead of this:

      useEffect(() => {
        window.addEventListener("scroll", handleScroll);
        return () => window.removeEventListener("scroll", handleScroll);
      }, []);
    

    It should be:

      useEffect(() => {
        window.addEventListener("scroll", handleScroll);
        return () => window.removeEventListener("scroll", handleScroll);
      }, [isVisible, handleScroll]);
    

    This is because when our component mounts for the first time (initial mount), the code inside your useEffect hook is called, and the eventListener is added. But as soon as setIsVisible is called in your handleScroll function, your useEffect clean-up callback function will be called, which then removes the eventListener. Since there are no "dependencies", it doesn’t get added again on the re-render of the component, and hence my suspicion it’s causing problems. If we add the two dependencies as I did in the second code snippet – [isVisible, handleScroll], this ensures that when we call setIsVisible and the component re-renders, it will, of course, call the clean-up function which removes the eventListener. But the difference now is that we add it again on re-render because we have them as dependencies!

    Maybe it works, maybe it doesn’t. The only issue we now have is that when we re-render each time, handleScroll is initialized each time. But this can be fixed by wrapping handleScroll in a useCallback. I’m not sure if this will work, but I think you can give it a shot.

    When using the callback I reckon I’d be something like:

    const handleScroll = useCallback(() => {
      const scroll = window.scrollY;
       
      const shouldBeVisible = scroll <= 40;
      if (shouldBeVisible === isVisible) return;
      setIsVisible(shouldBeVisible);
    }, [isVisible]);
    

    And the dependencies could then consist of the handleScroll function.

    useEffect(() => {
      window.addEventListener("scroll", handleScroll);
      return () => window.removeEventListener("scroll", handleScroll);
    }, [handleScroll]); // Add handleScroll as a dependency
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search