Skip to content

Instantly share code, notes, and snippets.

@joshuacerbito
Last active January 8, 2024 13:44
Show Gist options
  • Save joshuacerbito/ea318a6a7ca4336e9fadb9ae5bbb87f4 to your computer and use it in GitHub Desktop.
Save joshuacerbito/ea318a6a7ca4336e9fadb9ae5bbb87f4 to your computer and use it in GitHub Desktop.
Custom React hook for listening to scroll events
/**
* useScroll React custom hook
* Usage:
* const { scrollX, scrollY, scrollDirection } = useScroll();
*/
import { useState, useEffect } from "react";
export function useScroll() {
const [lastScrollTop, setLastScrollTop] = useState(0);
const [bodyOffset, setBodyOffset] = useState(
document.body.getBoundingClientRect()
);
const [scrollY, setScrollY] = useState(bodyOffset.top);
const [scrollX, setScrollX] = useState(bodyOffset.left);
const [scrollDirection, setScrollDirection] = useState();
const listener = e => {
setBodyOffset(document.body.getBoundingClientRect());
setScrollY(-bodyOffset.top);
setScrollX(bodyOffset.left);
setScrollDirection(lastScrollTop > -bodyOffset.top ? "down" : "up");
setLastScrollTop(-bodyOffset.top);
};
useEffect(() => {
window.addEventListener("scroll", listener);
return () => {
window.removeEventListener("scroll", listener);
};
});
return {
scrollY,
scrollX,
scrollDirection
};
}
@artokun
Copy link

artokun commented Apr 16, 2019

const [scrollY, setScrollYX] = useState(bodyOffset.top); should be const [scrollY, setScrollY] = useState(bodyOffset.top);

@tomekrozalski
Copy link

But in line 20 you don't have access to current lastScrollTop, because of line 29. lastScrollTop inside the listener will always return 0, am I wrong?

@joshuacerbito
Copy link
Author

const [scrollY, setScrollYX] = useState(bodyOffset.top); should be const [scrollY, setScrollY] = useState(bodyOffset.top);

Thanks, @artokun! I've fixed the typo error.

But in line 20 you don't have access to current lastScrollTop, because of line 29. lastScrollTop inside the listener will always return 0, am I wrong?

Hi, thanks, @tomekrozalski! You're right. The dependency array should not be present for that effect, so I went ahead and removed it.

@zshannon
Copy link

setBodyOffset isn't synchronous so you'll want to store the current value of getBoundingClientRect and refer to it throughout the state update calls. (Otherwise you'll miss the ultimate scroll event when scrolling to the top of the frame, eg)

const listener = () => {
  const rect = document.body.getBoundingClientRect()
  setBodyOffset(rect)
  setScrollY(-rect.top)
  setScrollX(rect.left)
  setScrollDirection(lastScrollTop > -rect.top ? 'down' : 'up')
  setLastScrollTop(-rect.top)
}

@gabehesse
Copy link

Does this look like it would render more than necessary? You always set those 5 values together so maybe put them all as attributes into one object so you only have one set state call. You might think about throttling too.

SSR is going to be an issue here.

Also, nitpick but your documentation at the top transposed the x and y, and I would expect the scrollbar moving down would result in a scroll direction of "down" not "up".

Thanks for this great start!

@Borming
Copy link

Borming commented Aug 13, 2019

Should use one object with property's with one setState. { scrollX : 200, scrollX : 0, offsetTop:0 ,...}. Also, consider using useMemo or useCallback for memoizing.

@gcosgreave-us
Copy link

I've added debounce to stop the event getting constantly fired if that helps anyone else too.. 🤷🏻‍♂️

/**
 * useScroll React custom hook
 * Usage:
 *    const { scrollX, scrollY, scrollDirection } = useScroll();
 */

import { useState, useEffect } from 'react';
import debounce from 'lodash.debounce';

export function useScroll() {
  const [lastScrollTop, setLastScrollTop] = useState(0);
  const [bodyOffset, setBodyOffset] = useState(
    document.body.getBoundingClientRect()
  );
  const [scrollY, setScrollY] = useState(bodyOffset.top);
  const [scrollX, setScrollX] = useState(bodyOffset.left);
  const [scrollDirection, setScrollDirection] = useState();

  const listener = (e) => {
    setBodyOffset(document.body.getBoundingClientRect());
    setScrollY(-bodyOffset.top);
    setScrollX(bodyOffset.left);
    setScrollDirection(lastScrollTop > -bodyOffset.top ? 'down' : 'up');
    setLastScrollTop(-bodyOffset.top);
  };

  const delay = 200;

  useEffect(() => {
    window.addEventListener('scroll', debounce(listener, delay));
    return () => {
      window.removeEventListener('scroll', listener);
    };
  });

  return {
    scrollY,
    scrollX,
    scrollDirection,
  };
}

@alatras
Copy link

alatras commented Aug 20, 2019

Screenshot 2019-08-20 at 09 44 17

Getting this error :/

@jwmann
Copy link

jwmann commented Sep 24, 2019

I tried to improve it by using a Ref for the previous scrollTop position which is the recommended way in the react docs

It's also in typescript.

import { useState, useEffect, useRef } from 'react';
import debounce from 'lodash/debounce';

export function useScroll() {
  const [bodyOffset, setBodyOffset] = useState<DOMRect | ClientRect>(
    document.body.getBoundingClientRect(),
  );
  const [scrollY, setScrollY] = useState<number>(bodyOffset.top);
  const [scrollX, setScrollX] = useState<number>(bodyOffset.left);
  const [scrollDirection, setScrollDirection] = useState<'up' | 'down'>();

  const lastScrollTopRef = useRef(0);
  const lastScrollTop = lastScrollTopRef.current;
  const listener = () => {
    setBodyOffset(document.body.getBoundingClientRect());
    setScrollY(-bodyOffset.top);
    setScrollX(bodyOffset.left);
    setScrollDirection(lastScrollTop > -bodyOffset.top ? 'down' : 'up');
    lastScrollTopRef.current = -bodyOffset.top;
  };

  const delay = 200;

  useEffect(() => {
    window.addEventListener('scroll', debounce(listener, delay));
    return () => window.removeEventListener('scroll', listener);
  });

  return {
    scrollY,
    scrollX,
    scrollDirection,
  };
}

Unfortunately I find it kind of inefficient even with the debounce.
Not sure if this is just my console logging or what.

I feel like it's creating a new event listener every time and not removing it.

@heymartinadams
Copy link

heymartinadams commented Oct 18, 2019

To remove the memory leak, I think you only need to call the useEffect once on mount. We can do this by adding a [] to the end of the useEffect function. See docs:

“If you want to run an effect and clean it up only once (on mount and unmount), you can pass an empty array ([]) as a second argument. This tells React that your effect doesn’t depend on any values from props or state, so it never needs to re-run.”

Next, it’s much simpler to combine all the hooks into a single hook that takes a single object containing all the data. Thus, we can simplify everything down to this:

import { useState, useEffect } from 'react'

export const useScroll = () => {

  // Set a single object `{ x: ..., y: ..., direction: ... }` once on init
  const [scroll, setScroll] = useState({
    x: document.body.getBoundingClientRect().left,
    y: document.body.getBoundingClientRect().top,
    direction: ''
  })

  const listener = e => {
    // `prev` provides us the previous state: https://reactjs.org/docs/hooks-reference.html#functional-updates
    setScroll(prev => ({
      x: document.body.getBoundingClientRect().left,
      y: -document.body.getBoundingClientRect().top,
      // Here we’re comparing the previous state to the current state to get the scroll direction
      direction: prev.y > -document.body.getBoundingClientRect().top ? 'up' : 'down'
    }))
  }

  useEffect(() => {
    window.addEventListener('scroll', listener)
    // cleanup function occurs on unmount
    return () => window.removeEventListener('scroll', listener)
  // Run `useEffect` only once on mount, so add `, []` after the closing curly brace }
  }, [])

  return scroll
}

ps

It might be better to use window.scrollX and window.scrollY instead of document.body.getBoundingClientRect().left / .top if overall, rather than relative, scroll position is needed.

@yasintz
Copy link

yasintz commented Oct 24, 2019

That helped. Thank you.

@martin2844
Copy link

Thank you so much for this hook!

@martin2844
Copy link

martin2844 commented Feb 10, 2020

Okay, sorry to double post.... if Any Gatsby or React SSR users come across this, of course, your gonna run into a document is undefined error.
For gatsby build time I have solved it by using a ternary when using the document element:

import { useState, useEffect } from "react";

export function useScroll() {

  

  const [lastScrollTop, setLastScrollTop] = useState(0);
  const [bodyOffset, setBodyOffset] = useState(
    typeof window === "undefined" || !window.document ? 0 : document.body.getBoundingClientRect()
  );
  const [scrollY, setScrollY] = useState(bodyOffset.top);
  const [scrollX, setScrollX] = useState(bodyOffset.left);
  const [scrollDirection, setScrollDirection] = useState();

  const listener = e => {
    setBodyOffset(typeof window === "undefined" || !window.document ? 0 : document.body.getBoundingClientRect());
    setScrollY(-bodyOffset.top);
    setScrollX(bodyOffset.left);
    setScrollDirection(lastScrollTop > -bodyOffset.top ? "down" : "up");
    setLastScrollTop(-bodyOffset.top);
  };

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

  return {
    scrollY,
    scrollX,
    scrollDirection
  };
}

@theskillwithin
Copy link

If your debouncing the listener, I think you are removing it wrong. you should correctly remove it by doing something like:

  useEffect(() => {
    const debounceWrapper = debounce(listener, 300)
    window.addEventListener('scroll', debounceWrapper)
    return () => {
      window.removeEventListener('scroll', debounceWrapper)
    }
  }, [])

otherwise you'll notice that if u do a console log in the listener and change pages it will still be firing.

@gusfune
Copy link

gusfune commented Jun 30, 2020

This hook is extremely useful and worked better than most libraries for such. I did some changes on the original one and also converted it to Typescript. Be free to use it or make improvements:

https://gist.github.com/gusfune/5ee7d6815db966ab16d88dda7cf414da

/**
 * useScroll React custom hook
 * Usage:
 *    const { scrollX, scrollY, scrollDirection } = useScroll();
 * Original Source: https://gist.github.com/joshuacerbito/ea318a6a7ca4336e9fadb9ae5bbb87f4
 */
import { useState, useEffect } from "react"

type SSRRect = {
  bottom: number
  height: number
  left: number
  right: number
  top: number
  width: number
  x: number
  y: number
}
const EmptySSRRect: SSRRect = {
  bottom: 0,
  height: 0,
  left: 0,
  right: 0,
  top: 0,
  width: 0,
  x: 0,
  y: 0,
}

const useScroll = () => {
  const [lastScrollTop, setLastScrollTop] = useState<number>(0)
  const [bodyOffset, setBodyOffset] = useState<DOMRect | SSRRect>(
    typeof window === "undefined" || !window.document
      ? EmptySSRRect
      : document.body.getBoundingClientRect()
  )
  const [scrollY, setScrollY] = useState<number>(bodyOffset.top)
  const [scrollX, setScrollX] = useState<number>(bodyOffset.left)
  const [scrollDirection, setScrollDirection] = useState<
    "down" | "up" | undefined
  >()

  const listener = () => {
    setBodyOffset(
      typeof window === "undefined" || !window.document
        ? EmptySSRRect
        : document.body.getBoundingClientRect()
    )
    setScrollY(-bodyOffset.top)
    setScrollX(bodyOffset.left)
    setScrollDirection(lastScrollTop > -bodyOffset.top ? "down" : "up")
    setLastScrollTop(-bodyOffset.top)
  }

  useEffect(() => {
    window.addEventListener("scroll", listener)
    return () => {
      window.removeEventListener("scroll", listener)
    }
  })

  return {
    scrollY,
    scrollX,
    scrollDirection,
  }
}

export { useScroll }

@inoumen
Copy link

inoumen commented Aug 14, 2020

Can you pls add a license? Because if you don't add it, unfortunately, no one does not have a right to use it.

@csandman
Copy link

csandman commented Oct 7, 2020

Here is my version that takes inspiration from a combination of the versions posted here but I've also included optional callbacks that can be added to the hook when using it.

https://gist.github.com/csandman/289787f26ae14566963ba611bf999c1f

// inspired by:
// https://gist.github.com/joshuacerbito/ea318a6a7ca4336e9fadb9ae5bbb87f4
import { useEffect, useState } from 'react';

const isValidFunction = (func) => {
  return func && typeof func === 'function';
};

export default function useScroll({ onScroll, onScrollUp, onScrollDown }) {
  const [scroll, setScroll] = useState(
    typeof window === 'undefined' || !window.document
      ? { x: 0, y: 0, direction: '' }
      : {
          x: document.body.getBoundingClientRect().left,
          y: -document.body.getBoundingClientRect().top,
          direction: '',
        }
  );

  useEffect(() => {
    const handleScroll = () => {
      setScroll((prevScroll) => {
        const rect =
          typeof window === 'undefined' || !window.document
            ? { left: 0, top: 0 }
            : document.body.getBoundingClientRect();
        const x = rect.left;
        const y = -rect.top;
        const direction = prevScroll.y > y ? 'up' : 'down';
        const newScroll = { x, y, direction };

        if (isValidFunction(onScroll)) {
          onScroll(newScroll);
        }
        if (direction === 'up' && isValidFunction(onScrollUp)) {
          onScrollUp(newScroll);
        }
        if (direction === 'down' && isValidFunction(onScrollDown)) {
          onScrollDown(newScroll);
        }

        return newScroll;
      });
    };

    window.addEventListener('scroll', handleScroll);

    return () => {
      window.removeEventListener('scroll', handleScroll);
    };
  }, [onScroll, onScrollDown, onScrollUp]);

  return scroll;
}

@chancity
Copy link

chancity commented Oct 9, 2020

import {useEffect, useState} from 'react';

export const DIRECTION = {
  down: 'DOWN',
  up: 'UP',
  unset: 'UNSET',
};

const getDocumentBoundingClientRect = (documentElement) =>
  typeof documentElement.getBoundingClientRect === 'function' ?
    documentElement.getBoundingClientRect() :
    {
      top: 0,
      left: 0,
    };

const getDocumentElement = (isServer) =>
  !isServer ?
    document.documentElement
    : {
      scrollHeight: 0,
      scrollWidth: 0,
      getBoundingClientRect: getDocumentBoundingClientRect,
    };

const getWindowSize = (isServer) => ({
  innerHeight: !isServer ? window.innerHeight : 0,
  innerWidth: !isServer ? window.innerWidth : 0,
});

const createScrollState = (lastScrollTop) => {
  const isServer = !process.browser;
  const documentElement = getDocumentElement(isServer);
  const bodyBoundingRect = documentElement.getBoundingClientRect();
  const windowSize = getWindowSize(isServer);

  const scrollY = bodyBoundingRect.top;
  const scrollX = bodyBoundingRect.left;
  const scrollYMax = documentElement.scrollHeight - windowSize.innerHeight;
  const scrollXMax = documentElement.scrollWidth - windowSize.innerWidth;
  const scrollDirection = lastScrollTop > bodyBoundingRect.top ? DIRECTION.down : DIRECTION.up;

  return {
    scrollY,
    scrollX,
    scrollDirection,
    scrollYMax,
    scrollXMax,
  }
};

const useWindowScroll = () => {
  const [state, setState] = useState(createScrollState(0));

  useEffect(() => {
    const listener = () =>
      setState(previousState => 
        createScrollState(previousState.scrollY)
      );

    window.addEventListener('scroll', listener);
    return () => {
      window.removeEventListener('scroll', listener);
    };
  }, []);

  return state;
};

export default useWindowScroll;

@1kvsn
Copy link

1kvsn commented Oct 10, 2020

Anyone tried any of above code for infinite scroll ?

@chancity
Copy link

chancity commented Oct 10, 2020

@1kvsn

import React, {useRef, useEffect} from 'react';

const useComponentScrollHook = (callBack) => {
  const ref = useRef(null);

  useEffect(() => {
    if (ref.current && callBack) {
      ref.current.addEventListener('scroll', callBack);
    }

    return () => {
      if (ref.current && callBack) {
        ref.current.removeEventListener('scroll', callBack);
      }
    };
  }, [ref, callBack]);

  return ref;
};

export default useComponentScrollHook;
 const scrollCallback = useCallback((e) => {
    const maxScroll = e.target.scrollHeight - e.target.offsetHeight;
    const scrollTop = e.target.scrollTop;
    const difference = maxScroll - scrollTop;
    if (difference <= 0 && !finished) {
      fetchData();
    }
  }, [finished, fetchData])

  const ref = useComponentScrollHook(scrollCallback);```

@nhuanhoangduc
Copy link

Only register 'scroll' event one time:

import { useState, useEffect, useCallback } from 'react'

export const useScroll = () => {
  const [state, setState] = useState({
    lastScrollTop: 0,
    bodyOffset: document.body.getBoundingClientRect(),
    scrollY: document.body.getBoundingClientRect().top,
    scrollX: document.body.getBoundingClientRect().left,
    scrollDirection: '', // down, up
  })

  const handleScrollEvent = useCallback((e) => {
    setState((prevState) => {
      const prevLastScrollTop = prevState.lastScrollTop
      const bodyOffset = document.body.getBoundingClientRect()

      return {
        setBodyOffset: bodyOffset,
        scrollY: -bodyOffset.top,
        scrollX: bodyOffset.left,
        scrollDirection: prevLastScrollTop > -bodyOffset.top ? 'down' : 'up',
        lastScrollTop: -bodyOffset.top,
      }
    })
  }, [])

  useEffect(() => {
    const scrollListener = (e) => {
      handleScrollEvent(e)
    }
    window.addEventListener('scroll', scrollListener)

    return () => {
      window.removeEventListener('scroll', scrollListener)
    }
  }, [handleScrollEvent])

  return {
    scrollY: state.scrollY,
    scrollX: state.scrollX,
    scrollDirection: state.scrollDirection,
  }
}

export default useScroll

@Mihai-github
Copy link

Mihai-github commented Oct 13, 2021

Hi,
Really nice what you all did above, my question is the first post made by @joshuacerbito is the updated version after all comments made by the community or anyone came with let's say a "better solution".

Thanks 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment