React Hooks, how to implement useHideOnScroll hook? - reactjs

const shouldHide = useHideOnScroll();
return shouldHide ? null : <div>something</div>
The useHideOnScroll behaviour should return updated value not on every scroll but only when there is a change.
The pseudo logic being something like the following:
if (scrolledDown && !isHidden) {
setIsHidden(true);
} else if (scrolledUp && isHidden) {
setIsHidden(false);
}
In words, if scroll down and not hidden, then hide. If scroll up and hidden, then unhide. But if scroll down and hidden, do nothing or scroll up and not hidden, do nothing.
How do you implement that with hooks?

Here:
const useHideOnScroll = () => {
const prevScrollY = React.useRef<number>();
const [isHidden, setIsHidden] = React.useState(false);
React.useEffect(() => {
const onScroll = () => {
setIsHidden(isHidden => {
const scrolledDown = window.scrollY > prevScrollY.current!;
if (scrolledDown && !isHidden) {
return true;
} else if (!scrolledDown && isHidden) {
return false;
} else {
prevScrollY.current = window.scrollY;
return isHidden;
}
});
};
console.log("adding listener");
window.addEventListener("scroll", onScroll);
return () => {
window.removeEventListener("scroll", onScroll);
};
}, []);
return isHidden;
};
const Navbar = () => {
const isHidden = useHideOnScroll();
console.info("rerender");
return isHidden ? null : <div className="navbar">navbar</div>;
};
export default Navbar;
You might have concern about setIsHidden causing rerender on every onScroll, by always returning some new state value, but a setter from useState is smart enough to update only if the value has actually changed.
Also your .navbar (I've added a class to it) shouldn't change the layout when it appears or your snippet will get locked in an infinite loop. Here're appropriate styles for it as well:
.navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 30px;
background: rgba(255, 255, 255, 0.8);
}
Full CodeSandbox: https://codesandbox.io/s/13kr4xqrwq

Using hooks in React(16.8.0+)
import React, { useState, useEffect } from 'react'
function getWindowDistance() {
const { pageYOffset: vertical, pageXOffset: horizontal } = window
return {
vertical,
horizontal,
}
}
export default function useWindowDistance() {
const [windowDistance, setWindowDistance] = useState(getWindowDistance())
useEffect(() => {
function handleScroll() {
setWindowDistance(getWindowDistance())
}
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [])
return windowDistance
}

You need to use window.addEventListener and https://reactjs.org/docs/hooks-custom.html guide.
That is my working example:
import React, { useState, useEffect } from "react";
const useHideOnScrolled = () => {
const [hidden, setHidden] = useState(false);
const handleScroll = () => {
const top = window.pageYOffset || document.documentElement.scrollTop;
setHidden(top !== 0);
};
useEffect(() => {
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, []);
return hidden;
};
export default useHideOnScrolled;
live demo: https://codesandbox.io/s/w0p3xkoq2l?fontsize=14
and i think name useIsScrolled() or something like that would be better

After hours of dangling, here is what I came up with.
const useHideOnScroll = () => {
const [isHidden, setIsHidden] = useState(false);
const prevScrollY = useRef<number>();
useEffect(() => {
const onScroll = () => {
const scrolledDown = window.scrollY > prevScrollY.current!;
const scrolledUp = !scrolledDown;
if (scrolledDown && !isHidden) {
setIsHidden(true);
} else if (scrolledUp && isHidden) {
setIsHidden(false);
}
prevScrollY.current = window.scrollY;
};
window.addEventListener("scroll", onScroll);
return () => {
window.removeEventListener("scroll", onScroll);
};
}, [isHidden]);
return isHidden;
};
Usage:
const shouldHide = useHideOnScroll();
return shouldHide ? null : <div>something</div>
It's still suboptimal, because we reassign the onScroll when the isHidden changes. Everything else felt too hacky and undocumented. I'm really interested in finding a way to do the same, without reassigning onScroll. Comment if you know a way :)

Related

Using cleanup function in React UseEffect

In my app there is a navbar that pops down after the user scrolled to a certain point. I use two separate navbars and define the current scroll position like this:
const newNavbar = () => {
if (window !== undefined) {
let posHeight_2 = window.scrollY;
posHeight_2 > 112 ? setNewNav(!newNav) : setNewNav(newNav)
}
};
const stickNavbar = () => {
if (window !== undefined) {
let windowHeight = window.scrollY;
windowHeight > 150 ? setSticky({ position: "fixed", top: "0", marginTop:"0", transition: "top 1s"}) : setSticky({});
}
};
const scrollPos = () => {
if (window !== undefined) {
let posHeight = window.scrollY;
posHeight > 112 ? setScroll(posHeight) : setScroll(0)
}
};
Current states are managed by useState and given to a class, which is triggered by the changing scroll position:
const [scroll, setScroll] = useState(0);
const [newNav, setNewNav] = useState (false)
const [sticky, setSticky] = useState({});
const navClass = newNav ? 'menu-2 show' : 'menu-2'
<Navbar className={navClass}>
//
</Navbar>
finally UseEffect to make use of the states:
React.useEffect(() => {
window.addEventListener('scroll', stickNavbar);
return () => window.removeEventListener('scroll', stickNavbar);
}, []);
React.useEffect(() => {
window.addEventListener('scroll', scrollPos);
return () => window.removeEventListener('scroll', stickNavbar);
}, []);
React.useEffect(() => {
window.addEventListener('scroll', newNavbar);
return () => window.removeEventListener('scroll', newNavbar);
}, []);
However my cleanup functions are not working, I get the error Warning: Can't perform a React state update on an unmounted component.
Your second useEffect contains a copy/paste error.
It should remove scrollPos (since that's what you bound), not stickNavbar.
Because of this scrollPos listener is not removed, which causes an error on the next scroll event, as the bound function no longer exists after the component is removed from DOM.

Updating React state in nested setTimeout callbacks

Can someone please tell me what's wrong with this and why the state of the 'video variable' remains false? So, even after the h2 element has rendered and is visible (i.e. the state of the video variable has been updated to true), when I click and call the hideVideo function, the video state remains false? Many thanks.
export default function App() {
const [message, showMessage] = useState(false);
const [video, setVideo] = useState(false);
let modalTimeout, videoTimeout;
useEffect(() => {
window.addEventListener("click", hideVideo);
setupTimeouts();
return () => {
clearTimeout(modalTimeout);
clearTimeout(videoTimeout);
};
}, []);
const setupTimeouts = () => {
modalTimeout = setTimeout(() => {
showMessage(true);
videoTimeout = setTimeout(() => {
showMessage(false);
setVideo(true);
}, 4000);
}, 2000);
};
const hideVideo = () => {
console.log(video);
showMessage(false);
if (video === true) {
setVideo(false);
}
};
return (
<div className="App">
{message && <h1>Message</h1>}
{video && <h2>Video</h2>}
</div>
);
}
When you call useEffect the window listener attach the default video value that is false to the function hideVideo() so it will be always false, I created a button to show you that the video state value does change. check the last test function
export default function App() {
const [message, showMessage] = useState(false);
const [video, setVideo] = useState(false);
let modalTimeout, videoTimeout;
useEffect(() => {
window.addEventListener("click", hideVideo);
setupTimeouts();
return () => {
clearTimeout(modalTimeout);
clearTimeout(videoTimeout);
};
}, []);
const setupTimeouts = () => {
modalTimeout = setTimeout(() => {
showMessage(true);
videoTimeout = setTimeout(() => {
showMessage(false);
setVideo(true);
}, 4000);
}, 2000);
};
const hideVideo = () => {
console.log(video);
showMessage(false);
if (video) {
setVideo(false);
}
};
const test = (event) => {
event.stopPropagation();
console.log(video)
}
return (
<>
{message && <h1>Message</h1>}
{video && <h2>Video</h2>}
<button onClick={test}>test</button>
</>
);
}

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.

How can I apply a global scroll event to multiple React components?

I'm building a React app and I'd like to have a global CSS class that is used to fade in components when they appear in the viewport.
jQuery
With jQuery, I might do something like this:
const windowHeight = (window.innerHeight || document.documentElement.clientHeight);
const windowWidth = (window.innerWidth || document.documentElement.clientWidth);
isInViewport(el) {
const rect = el.getBoundingClientRect();
const vertInView = (rect.top <= windowHeight) && ((rect.top + rect.height) >= 0);
const horInView = (rect.left <= windowWidth) && ((rect.left + rect.width) >= 0);
return (vertInView && horInView);
};
$(window).scroll(function(e) {
$('.animate').each(function() {
if(isInViewport($(this)[0])) {
$(this).addClass('animate--active');
}
});
});
On scroll, I'd check each element with the animate class and if that element is in the viewport, add the animate--active class to it, which will fade it in.
React
In React, I've moved my isInViewport() function to a global Helpers.js file so any component can make use of it, but I've had to add the scroll event and the dynamic class to every component, which makes for a lot of duplicated code. For example:
import { isInViewport } from './Helpers.js';
function MyComponent(props) {
const [inViewport, setInViewport] = useState(false);
const myComponentRef = useRef();
function handleScroll(e) {
setInViewport(isInViewport(myComponentRef.current));
}
useEffect(() => {
window.addEventListener('scroll', handleScroll);
// unmount
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, []);
const classes = (inViewport) ? 'animate animate--active' : 'animate';
return (
<section className={classes} ref={myComponentRef}>
</section>
);
}
As far as I can tell, this would be the React way of doing this, and this does work, but again, it means that every component would require its own state variable, scroll event and class declaration, which adds up to a lot of repetition. Is there a better way of doing this?
Custom Hooks, Custom Hooks, Custom Hooks
import { isInViewport } from './Helpers.js';
function useIsInViewPort(ref) {
const [inViewport, setInViewport] = React.useState(false);
function handleScroll(e) {
setInViewport(isInViewport(ref.current));
}
React.useEffect(() => {
window.addEventListener('scroll', handleScroll);
// unmount
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, []);
return inViewport;
}
function Acmp(props) {
const ref = React.useRef();
const inViewport = useIsInViewPort(ref);
const classes = (inViewport) ? 'animate animate--active' : 'animate';
return (
<section className={classes} ref={ref}>
</section>
);
}
function Bcmp(props) {
const ref = React.useRef();
const inViewport = useIsInViewPort(ref);
return (
<section className={classes} ref={ref}>
</section>
);
}

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