Search code examples
javascriptreactjsscrollevent-handling

Detect scroll direction in React js


I'm trying to detect if the scroll event is up or down, but I can't find the solution.

import React, { useState, useEffect } from "react";
import { Link } from "react-router-dom";

const Navbar = ({ className }) => {
  const [y, setY] = useState(0);

  const handleNavigation = (e) => {
    const window = e.currentTarget;
    if (y > window.scrollY) {
      console.log("scrolling up");
    } else if (y < window.scrollY) {
      console.log("scrolling down");
    }
    setY(window.scrollY);
  };

  useEffect(() => {
    setY(window.scrollY);

    window.addEventListener("scroll", (e) => handleNavigation(e));
  }, []);

  return (
    <nav className={className}>
      <p>
        <i className="fas fa-pizza-slice"></i>Food finder
      </p>
      <ul>
        <li>
          <Link to="/">Home</Link>
        </li>
        <li>
          <Link to="/about">About</Link>
        </li>
      </ul>
    </nav>
  );
};

export default Navbar;

Basically, it's always detected as "down" because y in handleNavigation is always 0. If I check the state in DevTool, the y state updates, but the handleNavigation doesn't.

Do you have any suggestions on what I am doing wrong?

Thanks for your help


Solution

  • TLDR;

    Since this answer has drawn some attention, I've just developed an npm package based on it to allow everyone to use it as an independent package/library in their projects. I also wrote a story on the Hackernoon and went through the details about this answer (This is published in more than 10 languages, so it may be easier for non-English readers to read through the details).

    If you want something to work with right away in React itself or serverside rendering frameworks/libraries like Nextjs, Remixjs, Gatsbyjs, etc., you can add it to your project as a dependency:

    Demo

    Edit react-scroll-direction

    npm i @smakss/react-scroll-direction
    or
    yarn add @smakss/react-scroll-direction
    

    Read more here.

    The answer and its description

    This is because you defined a useEffect() without any dependencies, so your useEffect() will only run once, and it never calls handleNavigation() on y changes. To fix this, you must add y to your dependency array to tell your useEffect() to run whenever the y value changes. Then it would be best if you had another change to take effect in your code, where you are trying to initialize your y with window.scrollY, so you should do this in your useState() like:

    const [y, setY] = useState(window.scrollY);
    
    useEffect(() => {
      window.addEventListener("scroll", (e) => handleNavigation(e));
    
      return () => { // return a cleanup function to unregister our function since it will run multiple times
        window.removeEventListener("scroll", (e) => handleNavigation(e));
      };
    }, [y]);
    

    If, for some reason, window was unavailable there or you don't want to do it here, you can do it in two separate useEffect()s.

    So your useEffect() should be like this:

    useEffect(() => {
      setY(window.scrollY);
    }, []);
    
    useEffect(() => {
      window.addEventListener("scroll", (e) => handleNavigation(e));
    
      return () => { // return a cleanup function to unregister our function since it will run multiple times
        window.removeEventListener("scroll", (e) => handleNavigation(e));
      };
    }, [y]);
    

    UPDATE (Working Solutions)

    After implementing this solution on my own, I found out some notes should be applied to this solution. So since the handleNavigation() will change y value directly, we can ignore the y as our dependency and then add handleNavigation() as a dependency to our useEffect(), then due to this change we should optimize handleNavigation(), so we should use useCallback() for it. Then, the final result will be something like this:

    const [y, setY] = useState(window.scrollY);
    
    const handleNavigation = useCallback(
      e => {
        const window = e.currentTarget;
        if (y > window.scrollY) {
          console.log("scrolling up");
        } else if (y < window.scrollY) {
          console.log("scrolling down");
        }
        setY(window.scrollY);
      }, [y]
    );
    
    useEffect(() => {
      setY(window.scrollY);
      window.addEventListener("scroll", handleNavigation);
    
      return () => {
        window.removeEventListener("scroll", handleNavigation);
      };
    }, [handleNavigation]);
    

    After a comment from @RezaSam, I noticed a tiny mistake in the memoized version. Where I call handleNavigation within another arrow function, I found out (via the browser dev tool, event listeners tab) that each component will register a new event to the window, so it might ruin the whole thing.

    Working demo:

    CodeSandbox


    Final Optimised Solution

    After all, I concluded that memoization, in this case, will help us register a single event to recognize the scroll direction. Still, it is not fully optimized for printing the consoles because we are consoling inside the handleNavigation function. There is no way to print the desired consoles in the current implementation.

    So, I realized there is a better way to store the last page scroll position each time we want to check a new status. Also, to eliminate a vast amount of consoling scrolling up and scrolling down, we should define a threshold (Use debounce approach) to trigger the scroll event change. So I just searched the web a bit and ended up with this gist, which was very useful. Then, with the inspiration of it, I implemented a simpler version.

    This is how it looks:

    const [scrollDir, setScrollDir] = useState("scrolling down");
    
    useEffect(() => {
      const threshold = 0;
      let lastScrollY = window.pageYOffset;
      let ticking = false;
    
      const updateScrollDir = () => {
        const scrollY = window.pageYOffset;
    
        if (Math.abs(scrollY - lastScrollY) < threshold) {
          ticking = false;
          return;
        }
        setScrollDir(scrollY > lastScrollY ? "scrolling down" : "scrolling up");
        lastScrollY = scrollY > 0 ? scrollY : 0;
        ticking = false;
      };
    
      const onScroll = () => {
        if (!ticking) {
          window.requestAnimationFrame(updateScrollDir);
          ticking = true;
        }
      };
    
      window.addEventListener("scroll", onScroll);
      console.log(scrollDir);
    
      return () => window.removeEventListener("scroll", onScroll);
    }, [scrollDir]);
    

    How it works?

    I will go from top to bottom and explain each code block.

    • So I just defined a threshold point with the initial value of 0; whenever the scroll goes up or down, it will make a new calculation. You can increase it if you don't want to calculate a new page offset immediately.

    • Then, instead of scrollY, I decided to use pageYOffset, which is more reliable in cross-browsing.

    • In the updateScrollDir function, we will check if the threshold is met; then, if it is completed, I will specify the scroll direction based on the current and previous page offset.

    • The most crucial part of it is the onScroll function. I just used requestAnimationFrame to ensure that we calculate the new offset after the page is rendered wholly after scrolling. And then, with the ticking flag, we will ensure we are just running our event listener callback once in each requestAnimationFrame.

    • At last, we defined our listener and our cleanup function.

    The scrollDir state will then contain the actual scroll direction.

    Working demo:

    CodeSandbox