useScrollDirection Hook makes slide in button to flicker - reactjs

It is quite simple yet annoying to go around.
While scrolling down on mobile device, nothing should be happening. But the moment you scroll upwards i am using a Custom Hook to recognise direction and then trigger transition for button to slide in.
However, as the screen is still moving, my Hook is still re-rendering and makes my button flicker. It does slide in if the scrolling stopps.
I just cant get around this to make it slide in smoothly.
Here is the Hook.
import { useEffect, useState } from "react"
export const SCROLL_UP = "up"
export const SCROLL_DOWN = "down"
export const useScrollDirection = (
initialDirection = SCROLL_DOWN,
thresholdPixels = 20
) => {
const [scrollDir, setScrollDir] = useState(initialDirection)
useEffect(() => {
const threshold = thresholdPixels || 0
let lastScrollY = window.pageYOffset
let ticking = false
const updateScrollDir = () => {
const scrollY = window.pageYOffset
if (Math.abs(scrollY - lastScrollY) < threshold) {
// We haven't exceeded the threshold
ticking = false
return
}
setScrollDir(scrollY > lastScrollY ? SCROLL_DOWN : SCROLL_UP)
lastScrollY = scrollY > 0 ? scrollY : 0
ticking = false
}
const onScroll = () => {
if (!ticking) {
window.requestAnimationFrame(updateScrollDir)
ticking = true
}
}
window.addEventListener("scroll", onScroll)
return () => window.removeEventListener("scroll", onScroll)
}, [initialDirection, thresholdPixels])
return scrollDir
}

Related

How can I properly debounce a wheel event in ReactJS?

Made a codesandbox of the issue : https://codesandbox.io/s/serene-rosalind-fcitpd?file=/src/Photo.tsx
The real problem comes when you change scroll direction, the index photo will glitch a bit ...
I have a photo gallery:https://virgile-hasselmann.vercel.app/photos. I want to switch the photos when the user scrolls up or down. To do so I thought I'd use wheelEvent and the e.deltaY. It kinda works but I had to add a useDebounce function because if not it would glitch the gallery. Here you can see the custom hook :
function useDebounce<T>(value: T, delay?: number) {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
const setDebounce = (newValue: T) => {
setTimeout(() => setDebouncedValue(newValue), delay || 500);
};
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return { debouncedValue, setDebounce };
}
In the Gallery component, here is how I've implemented the wheel event and the debouncing :
// Init the data to display with the photos of the first category
const [photoIdx, setPhotoIdx] = useState(0);
const { debouncedValue, setDebounce } = useDebounce(photoIdx, 1000);
const setDebouncedIdx = (value: number) => {
setDebounce(value);
setPhotoIdx(debouncedValue);
};
const handleWheel = (e: WheelEvent<HTMLDivElement>) => {
if (e.deltaY > 0) {
if (photoIdx < dataSelected!.length - 1) {
setDebouncedIdx(photoIdx + 1);
} else {
setDebouncedIdx(0);
}
} else {
if (photoIdx > 0) {
setDebouncedIdx(photoIdx - 1);
} else {
setDebouncedIdx(dataSelected!.length - 1);
}
}
};
But the result does not satisfy me the least: it's glitchy and not really responding the user's input + if you try to go back by scrolling back, it will first show you the next photo before going back to it. Hard to explain word but it's quite clear if you look at the index at the bottom left corner.
Perhaps there is a better way of implementing it. The problem is unclear to me if anyone could enlighten me that would much appreciated :)
I think you can use an eventListener to detect if the user is scrolling. You can detect the direction of the scrolling too. So if the user scrolls down then you replace the image with the next one. Or scroll up then replace it with the previous one.
The threshold is used to determine how much you want to scroll before taking an action.
useEffect(() => {
//Scroll listener to determine the scroll direction
const threshold = 0;
let lastScrollY = window.scrollY;
let scrollDir = "";
const updateScrollDir = () => {
const scrollY = window.scrollY;
if (Math.abs(scrollY - lastScrollY) < threshold) {
return;
}
scrollDir = (scrollY > lastScrollY ? "scrolling down" : "scrolling up");
lastScrollY = scrollY > 0 ? scrollY : 0;
if (scrollDir == "scrolling up") {
//Do something to display the previous image
}
if (scrollDir == "scrolling down") {
//Do something to display the next image
}
};
const onScroll = () => {
window.requestAnimationFrame(updateScrollDir);
};
window.addEventListener("scroll", onScroll);
return () => {
window.removeEventListener("scroll", onScroll);
}
},

Framer-motion Animation With Typescript: ScrollY doesn't find .current

I'm trying to create an animation that hides the menu when the user scrolls down and when the user returns to the top the menu appears.
Everything works, but the problem comes when I try to give to the scrollY property that gives the useScroll hook a type.
This is the code that I could not find the way to implement the type.
const { scrollY } = useScroll();
const [isScrolling, setScrolling] = useState<boolean>(false);
const handleScroll = () => {
if (scrollY?.current < scrollY?.prev) setScrolling(false);
if (scrollY?.current > 100 && scrollY?.current > scrollY?.prev) setScrolling(true);
};
useEffect(() => {
return scrollY.onChange(() => handleScroll());
});
I found the solution!
Reading the documentation framer, I saw that they updated the way to get the value of the previous and current scroll.
scrollY.get() -> Current
scrollY.getPrevious() -> Previous
const { scrollY } = useScroll();
const [isScrolling, setScrolling] = useState<boolean>(false);
const handleScroll = () => {
if (scrollY.get() < scrollY.getPrevious()) setScrolling(false);
if (scrollY.get() > 100 && scrollY.get() > scrollY.getPrevious()) setScrolling(true);
};
useEffect(() => {
return scrollY.onChange(() => handleScroll());
});

React useScroll Hook keep track of previousScrollTop

So I've followed this:
https://www.pinkdroids.com/blog/moving-header-react-hooks-context/
And managed to get it working on one app.
I'm not replicating what I did in another app and it's not working.
I've got as far as working out that my previousScrollTop and currentScrollTop are always the same – therefore no direction is being added
But why are they the same!!!
I've gone as far as copy and pasting the code so no issues there.
Could this be a problem with memo?
with my lodash import?
Not getting any errors.
UPDATE*
The problem is either with useMemo, or useRef in my useScroll hook.
const getScrollPosition = () => {
if (typeof window === 'undefined') {
return 0
}
return (
window.pageYOffset ||
document.documentElement.scrollTop ||
document.body.scrollTop ||
0
)
}
export const useScroll = (timeout = 250) => {
const defaultScrollTop = useMemo(() => getScrollPosition(), [])
const previousScrollTop = useRef(defaultScrollTop)
const [currentScrollTop, setCurrentScrollTop] = useState(defaultScrollTop)
console.log(currentScrollTop)
useEffect(() => {
const handleDocumentScroll = () => {
const scrollTop = getScrollPosition()
setCurrentScrollTop(scrollTop)
console.log(currentScrollTop)
previousScrollTop.current = scrollTop
console.log(previousScrollTop.current)
}
const handleDocumentScrollThrottled = throttle(handleDocumentScroll, timeout)
document.addEventListener('scroll', handleDocumentScrollThrottled)
return () => {
document.removeEventListener('scroll', handleDocumentScrollThrottled)
}
}, [timeout])
return {
scrollTop: currentScrollTop,
previousScrollTop: previousScrollTop.current,
time: timeout,
}
}
UPDATE*
I've managed to work a fix with a setTimeout inside my handleDocumentScroll... Should delay between the values...
But still unsure why this worked before as it was and not now....
const handleDocumentScroll = () => {
const scrollTop = getScrollPosition()
setCurrentScrollTop(scrollTop)
setTimeout(function () {
previousScrollTop.current = scrollTop
}, 200); // update previous 200ms after event fires
}

useEffect triggers function several times with proper dependencies

i've got Tabs component, it has children Tab components. Upon mount it calculates meta data of Tabs and selected Tab. And then sets styles for tab indicator. For some reason function updateIndicatorState triggers several times in useEffect hook every time active tab changes, and it should trigger only once. Can somebody explain me what I'm doing wrong here? If I remove from deps of 2nd useEffect hook function itself and add a value prop as dep. It triggers correctly only once. But as far as I've read docs of react - I should not cheat useEffect dependency array and there are much better solutions to avoid that.
import React, { useRef, useEffect, useState, useCallback } from 'react';
import PropTypes from 'prop-types';
import { defProperty } from 'helpers';
const Tabs = ({ children, value, orientation, onChange }) => {
console.log(value);
const indicatorRef = useRef(null);
const tabsRef = useRef(null);
const childrenWrapperRef = useRef(null);
const valueToIndex = new Map();
const vertical = orientation === 'vertical';
const start = vertical ? 'top' : 'left';
const size = vertical ? 'height' : 'width';
const [mounted, setMounted] = useState(false);
const [indicatorStyle, setIndicatorStyle] = useState({});
const [transition, setTransition] = useState('none');
const getTabsMeta = useCallback(() => {
console.log('getTabsMeta');
const tabsNode = tabsRef.current;
let tabsMeta;
if (tabsNode) {
const rect = tabsNode.getBoundingClientRect();
tabsMeta = {
clientWidth: tabsNode.clientWidth,
scrollLeft: tabsNode.scrollLeft,
scrollTop: tabsNode.scrollTop,
scrollWidth: tabsNode.scrollWidth,
top: rect.top,
bottom: rect.bottom,
left: rect.left,
right: rect.right,
};
}
let tabMeta;
if (tabsNode && value !== false) {
const wrapperChildren = childrenWrapperRef.current.children;
if (wrapperChildren.length > 0) {
const tab = wrapperChildren[valueToIndex.get(value)];
tabMeta = tab ? tab.getBoundingClientRect() : null;
}
}
return {
tabsMeta,
tabMeta,
};
}, [value, valueToIndex]);
const updateIndicatorState = useCallback(() => {
console.log('updateIndicatorState');
let _newIndicatorStyle;
const { tabsMeta, tabMeta } = getTabsMeta();
let startValue;
if (tabMeta && tabsMeta) {
if (vertical) {
startValue = tabMeta.top - tabsMeta.top + tabsMeta.scrollTop;
} else {
startValue = tabMeta.left - tabsMeta.left;
}
}
const newIndicatorStyle =
((_newIndicatorStyle = {}),
defProperty(_newIndicatorStyle, start, startValue),
defProperty(_newIndicatorStyle, size, tabMeta ? tabMeta[size] : 0),
_newIndicatorStyle);
if (isNaN(indicatorStyle[start]) || isNaN(indicatorStyle[size])) {
setIndicatorStyle(newIndicatorStyle);
} else {
const dStart = Math.abs(indicatorStyle[start] - newIndicatorStyle[start]);
const dSize = Math.abs(indicatorStyle[size] - newIndicatorStyle[size]);
if (dStart >= 1 || dSize >= 1) {
setIndicatorStyle(newIndicatorStyle);
if (transition === 'none') {
setTransition(`${[start]} 0.3s ease-in-out`);
}
}
}
}, [getTabsMeta, indicatorStyle, size, start, transition, vertical]);
useEffect(() => {
const timeout = setTimeout(() => {
setMounted(true);
}, 350);
return () => {
clearTimeout(timeout);
};
}, []);
useEffect(() => {
if (mounted) {
console.log('1st call mounted');
updateIndicatorState();
}
}, [mounted, updateIndicatorState]);
let childIndex = 0;
const childrenItems = React.Children.map(children, child => {
const childValue = child.props.value === undefined ? childIndex : child.props.value;
valueToIndex.set(childValue, childIndex);
const selected = childValue === value;
childIndex += 1;
return React.cloneElement(child, {
selected,
indicator: selected && !mounted,
value: childValue,
onChange,
});
});
const styles = {
[size]: `${indicatorStyle[size]}px`,
[start]: `${indicatorStyle[start]}px`,
transition,
};
console.log(styles);
return (
<>
{value !== 2 ? (
<div className={`tabs tabs--${orientation}`} ref={tabsRef}>
<span className="tab__indicator-wrapper">
<span className="tab__indicator" ref={indicatorRef} style={styles} />
</span>
<div className="tabs__wrapper" ref={childrenWrapperRef}>
{childrenItems}
</div>
</div>
) : null}
</>
);
};
Tabs.defaultProps = {
orientation: 'horizontal',
};
Tabs.propTypes = {
children: PropTypes.node.isRequired,
value: PropTypes.number.isRequired,
orientation: PropTypes.oneOf(['horizontal', 'vertical']),
onChange: PropTypes.func.isRequired,
};
export default Tabs;
useEffect(() => {
if (mounted) {
console.log('1st call mounted');
updateIndicatorState();
}
}, [mounted, updateIndicatorState]);
This effect will trigger whenever the value of mounted or updateIndicatorState changes.
const updateIndicatorState = useCallback(() => {
...
}, [getTabsMeta, indicatorStyle, size, start, transition, vertical]);
The value of updateIndicatorState will change if any of the values in its dep array change, namely getTabsMeta.
const getTabsMeta = useCallback(() => {
...
}, [value, valueToIndex]);
The value of getTabsMeta will change whenever value or valueToIndex changes. From what I'm gathering from your code, value is the value of the selected tab, and valueToIndex is a Map that is re-defined on every single render of this component. So I would expect the value of getTabsMeta to be redefined on every render as well, which will result in the useEffect containing updateIndicatorState to run on every render.

When to use hooks? is worth it that example?

I have write a hook to check if browser is IE, so that I can reutilize the logic instead of write it in each component..
const useIsIE = () => {
const [isIE, setIsIE] = useState(false);
useEffect(() => {
const ua = navigator.userAgent;
const isIe = ua.indexOf("MSIE ") > -1 || ua.indexOf("Trident/") > -1;
setIsIE(isIe);
}, []);
return isIE;
}
export default useIsIE;
Is it worth it to use that hook?
Im not sure if is good idea because that way, Im storing a state and a effect for each hook call (bad performane?) when I can simply use a function like that:
export default () => ua.indexOf("MSIE ") > -1 || ua.indexOf("Trident/") > -1;
What do you think? is worth it use that hook or not?
If not, when should I use hooks and when not?
ty
No. Not worth using the hook.
You'd need to use a hook when you need to tab into React's underlying state or lifecycle mechanisms.
Your browser will probably NEVER change during a session so just creating a simple utility function/module would suffice.
I would recommend to set your browser checks in constants and not functions, your browser will never change.
...
export const isChrome = /Chrome/.test(userAgent) && /Google Inc/.test(navigator.vendor);
export const isIOSChrome = /CriOS/.test(userAgent);
export const isMac = (navigator.platform.toUpperCase().indexOf('MAC') >= 0);
export const isIOS = /iphone|ipad|ipod/.test(userAgent.toLowerCase());
...
This is a simple hook that checks if a element has been scrolled a certain amount of pixels
const useTop = (scrollable) => {
const [show, set] = useState(false);
useEffect(() => {
const scroll = () => {
const { scrollTop } = scrollable;
set(scrollTop >= 50);
};
const throttledScroll = throttle(scroll, 200);
scrollable.addEventListener('scroll', throttledScroll, false);
return () => {
scrollable.removeEventListener('scroll', throttledScroll, false);
};
}, [show]);
return show;
};
Then you can use it in a 'To Top' button to make it visible
...
import { tween } from 'shifty';
import useTop from '../../hooks/useTop';
// scrollRef is your scrollable container ref (getElementById)
const Top = ({ scrollRef }) => {
const t = scrollRef ? useTop(scrollRef) : false;
return (
<div
className={`to-top ${t ? 'show' : ''}`}
onClick={() => {
const { scrollTop } = scrollRef;
tween({
from: { x: scrollTop },
to: { x: 0 },
duration: 800,
easing: 'easeInOutQuart',
step: (state) => {
scrollRef.scrollTop = state.x;
},
});
}}
role="button"
>
<span><ChevronUp size={18} /></span>
</div>
);
};

Resources