Skip to content

Instantly share code, notes, and snippets.

@joshuacerbito
Last active January 8, 2024 13:44
Show Gist options
  • Star 81 You must be signed in to star a gist
  • Fork 14 You must be signed in to fork a gist
  • 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
};
}
@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