-
-
Save joshuacerbito/ea318a6a7ca4336e9fadb9ae5bbb87f4 to your computer and use it in GitHub Desktop.
/** | |
* 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 | |
}; | |
} |
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.
That helped. Thank you.
Thank you so much for this hook!
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
};
}
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.
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 }
Can you pls add a license? Because if you don't add it, unfortunately, no one does not have a right to use it.
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;
}
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;
Anyone tried any of above code for infinite scroll ?
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);```
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
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 👍
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.
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.