I'm trying to control the visibility of a React Component based on whether an individual is scrolling down on the component. The visibility is passed into the Fade element as the "in" property.
I've set up a listener using the UseEffect Hook, which adds the listener onMount. The actual onScroll function is supposed to update the scrollTop state (which is the current value of the height to the top of the page) and then the scrolling state (which compares the event's scroll to the top of the page with the previous state, and if the first is greater than the second, returns true).
However, for some reason the setScrollTop hook isn't working, and the scrolling state continues to stay at 0.
What am I doing wrong? Here's the full component:
export const Header = (props) => {
const classes = useStyles();
const [scrolling, setScrolling] = useState(false);
const [scrollTop, setScrollTop] = useState(0);
const onScroll = (e) => {
setScrollTop(e.target.documentElement.scrollTop);
setScrolling(e.target.documentElement.scrollTop > scrollTop);
}
useEffect(() => {
window.addEventListener('scroll', onScroll);
},[]);
useEffect(() => {
console.log(scrollTop);
}, [scrollTop])
return (
<Fade in={!scrolling}>
<AppBar className={classes.header} position="fixed">
....
You're missing the dependencies in your hook. Try this:
useEffect(() => {
const onScroll = e => {
setScrollTop(e.target.documentElement.scrollTop);
setScrolling(e.target.documentElement.scrollTop > scrollTop);
};
window.addEventListener("scroll", onScroll);
return () => window.removeEventListener("scroll", onScroll);
}, [scrollTop]);
By moving onScroll inside the useEffect, you don't need to track it on the hook's dependencies, however since it uses scrollTop from the component's scope, you'll need to add it.
Alternatively, if for some reason you don't want to move onScroll definition inside the useEffect, you'll need to wrap onScroll in useCallback and track it in useEffect's dependency array.
In general I'd recommend adding react-hooks/exhaustive-deps to your ESlint rules
Also it's a good idea to remove the event listener in cleanup function.
Or you can use window.pageYOffset. It's a bit more understandable for me that way:
const [scrolling, setScrolling] = useState(false);
const [scrollTop, setScrollTop] = useState(0);
useEffect(() => {
function onScroll() {
let currentPosition = window.pageYOffset; // or use document.documentElement.scrollTop;
if (currentPosition > scrollTop) {
// downscroll code
setScrolling(false);
} else {
// upscroll code
setScrolling(true);
}
setScrollTop(currentPosition <= 0 ? 0 : currentPosition);
}
window.addEventListener("scroll", onScroll);
return () => window.removeEventListener("scroll", onScroll);
}, [scrollTop]);
Related
I know this subject is an old one. But I think I have something new or at least I can't find any question with these characteristics. I can't remove eventListener, not even with React.useCallback. So, what do I do now?
Here's my code, at the start of my component's class:
const noCursorEventListener = React.useCallback((e) => {
console.log('ncel');
let lista = document.getElementsByClassName('lista');
if (lista && lista[0]) lista[0].classList.remove('nocursor');
}, []);
window.addEventListener('mousemove', noCursorEventListener);
The useEffect I use to remove it:
useEffect(() => {
return () => {
window.removeEventListener('mousemove', noCursorEventListener);
window.onmousemove = null;
console.log('remove el');
}
});
I see remove el correctly, but after that and after page changes I still got a ncel message. Any ideas? That window.onmousemove = null shouldn't be necessary. Was a test that failed.
TL;DR Don't do (non-hook) side-effects in render. :-) For lurkers, if you're just looking for how to properly add the event listener using DOM methods (in the rare cases where that's appropriate), see The Standard Way below.
But for those interested in why the OP's code didn't work:
Why That Didn't Work
What you have will work for the first render, but not subsequent ones. (If you're using React's StrictMode, it may have been rendering twice at the outset.) You can see why if we log a message at each stage of what's happening (I've changed mousemove to click because it doesn't matter and it avoid cluttering the log):
const { useState, useEffect } = React;
const Example = () => {
const noCursorEventListener = React.useCallback((e) => {
console.log("event listener called!");
}, []);
console.log("Adding event listener");
window.addEventListener("click", noCursorEventListener);
useEffect(() => {
return () => {
console.log("Removing event listener");
window.removeEventListener("click", noCursorEventListener);
};
});
const [counter, setCounter] = useState(0);
const increment = (event) => {
setCounter(c => c + 1);
event.stopPropagation();
};
return (
<div>
{counter} <input type="button" value="+" onClick={() => setCounter((c) => c + 1)} />
</div>
);
};
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<Example />);
<div id="root"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.1.0/umd/react.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.1.0/umd/react-dom.development.js"></script>
If you run that, you'll see Adding event listener because the render adds the event listener. And if you click somewhere other than the button, you'll see event listener called!. But if you click the button to cause a second render, you'll see this sequence:
Adding event listener
Removing event listener
Note the order. It re-adds the event listener (which doesn't do anything, because you can't add the same event listener function for the same event to the same element more than once), and then after the render the useEffect cleanup for the previous render runs, removing the event listener. This is implicit in the way useEffect cleanup works, but it can seem a bit surprising.
Amusingly, if you weren't memoizing the event listener, it would work because when adding, it would add a second event listener briefly, and then the first would be removed by the useEffect cleanup.
const { useState, useEffect } = React;
const Example = () => {
const noCursorEventListener = /*React.useCallback(*/(e) => {
console.log("event listener called!");
}/*, [])*/;
console.log("Adding event listener");
window.addEventListener("click", noCursorEventListener);
useEffect(() => {
return () => {
console.log("Removing event listener");
window.removeEventListener("click", noCursorEventListener);
};
});
const [counter, setCounter] = useState(0);
const increment = (event) => {
setCounter(c => c + 1);
event.stopPropagation();
};
return (
<div>
{counter} <input type="button" value="+" onClick={() => setCounter((c) => c + 1)} />
</div>
);
};
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<Example />);
<div id="root"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.1.0/umd/react.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.1.0/umd/react-dom.development.js"></script>
But don't do that. Other than calls to hooks, your render function should be pure (it shouldn't have meaningful side-effects). Adding an event listener is a meaningful side-effect.
Side effects are the whole point of useEffect (more here). So let's do it the standard way, by hooking up the listener in a useEffect callback and removing that same handler when the cleanup for that effect is done. (That also means we don't create a new listener function every time that we throw away.)
The Standard Way
Here's the standard way to add an event listener on mount and remove it on unmount, for those relatively rare use cases where doing this with the DOM directly is appropriate:
useEffect(() => {
const noCursorEventListener = (e) => {
let lista = document.getElementsByClassName("lista");
if (lista && lista[0]) lista[0].classList.remove("nocursor");
};
window.addEventListener("mousemove", noCursorEventListener);
return () => {
window.removeEventListener("mousemove", noCursorEventListener);
};
}, []); // <== Empty dependencies array = only run effect on mount
(There's also a separate issue: useCallback is a performance optimization, not a semantic guarantee. useCallback is a wrapper around useMemo, which has this disclaimer (their emphasis): "You may rely on useMemo as a performance optimization, not as a semantic guarantee." But your code was relying on it as a semantic guarantee.)
this is probably really simple but anyone know how to make a long press button in react. Like a button where if you press and hold for 2 seconds it is "clicked", otherwise it is not. Is there a react mouse event for this? Maybe I use onMouseDown event in some clever way?
One of the hack you can try is that you note the time when 'onMouseDown' event is fired and also note the time when 'onMouseUp' is fired.
If the difference between these times is greater than equal to 2 seconds, you can perform the action you want.
You must write logic to check the time difference and the code you want to execute, in method which will get executed when 'onMouseUp' event is fired.
delay = 2000;
startPress = null;
function mouseDown() {
startPress = Date.now();
}
function mouseUp() {
if(Date.now() - startPress > delay)
{
// your code
}
}
I used the following to create a button that dynamically changes class while you hold down the button. The use of setInterval instead of setTimeout simplifies the operation, since we manage the end-count ourselves. We don't do any type of DateTime calculations, and we can actively monitor the current delay milliseconds by watching currentCount.
I'm using DaisyUI as my component framework, but any CSS classes could be used.
import { useCallback, useEffect, useRef, useState } from 'react';
export type LongPressButtonProps = {
/** Text that will appear within the button */
buttonText: string;
/** Function to run once delay has been exceeded */
onExecute: () => void;
/** How long should be button be held before firing onExecute */
delayMs: number;
/** How frequently should the count be updated */
refreshMs: number;
};
const LongPressButton = ({
delayMs,
onExecute,
buttonText,
refreshMs = 100,
}: LongPressButtonProps) => {
const [mouseDown, setMouseDown] = useState(false);
const [currentCount, setCurrentCount] = useState(0);
const intervalRef = useRef<NodeJS.Timer>();
const [buttonClass, setButtonClass] = useState('btn-primary');
const onInterval = useCallback(() => {
setCurrentCount((c) => c + refreshMs);
}, [setCurrentCount, refreshMs]);
useEffect(() => {
if (mouseDown) intervalRef.current = setInterval(onInterval, refreshMs);
if (!mouseDown && intervalRef.current) {
clearInterval(intervalRef.current);
setCurrentCount(0);
setButtonClass(`btn-primary`);
}
}, [onInterval, delayMs, mouseDown, refreshMs]);
useEffect(() => {
if (currentCount > 0) setButtonClass(`btn-error`);
if (currentCount > delayMs) {
onExecute();
setCurrentCount(0);
}
}, [currentCount, delayMs, onExecute]);
return (
<button
className={`btn ${buttonClass}`}
onMouseDown={() => setMouseDown(true)}
onMouseUp={() => setMouseDown(false)}
onMouseLeave={() => setMouseDown(false)}
onTouchStart={() => setMouseDown(true)}
onTouchEnd={() => setMouseDown(false)}
style={{ transition: `${delayMs}ms` }}
>
{buttonText}
</button>
);
};
export default LongPressButton;
I am working with something like fullpage.js with React, and I need to remove the eventListener while the transition is ongoing.
Is it possible?
React code
function App() {
const wheelHandler = (event) => {
// I need to remove wheelHandler here
setTimeout(() => {
// I need to readd wheelHandler here
}, 1000); // Assume that the transition ends after 1000ms
};
return (
<div className="App" onWheel={wheelHandler} />
);
}
Vanilla JS equivalent
const wheelHandler = (event) => {
window.removeEventListener(wheelHandler);
setTimeout(() => {
window.addEventListener(wheelHandler);
}, 1000);
};
window.addEventListener(wheelHandler);
P.S. I tried the Vanilla JS solution on React but the event handler got triggered multiple times on one wheel scroll. Therefore I got no choice but React's SyntheticEvent.
With the way you're hooking it up, you can't without using a piece of state that tells you whether to hook up the handler and re-rendering, which is probably overkill.
Instead, I'd set a flag (perhaps on an object via a ref) telling the handler to ignore calls during the time you want calls ignored.
Something long these lines:
function App() {
const {current: scrolling} = useRef({flag: false});
const wheelHandler = (event) => {
// I need to remove wheelHandler here
if (scrolling.flag) {
// Bail out
return;
}
scrolling.flag = true;
// ...other logic if any...
setTimeout(() => {
// I need to readd wheelHandler here
scrolling.flag = false;
}, 1000); // Assume that the transition ends after 1000ms
};
return (
<div className="App" onWheel={wheelHandler} />
);
}
Or you can also do it like this, you don't need an extra object (I tend to prefer to use a single ref that holds all of my non-state instance data, but you don't have to):
function App() {
const scrolling = useRef(false);
const wheelHandler = (event) => {
// I need to remove wheelHandler here
if (scrolling.current) {
// Bail out
return;
}
scrolling.current = true;
// ...other logic if any...
setTimeout(() => {
// I need to readd wheelHandler here
scrolling.current = false;
}, 1000); // Assume that the transition ends after 1000ms
};
return (
<div className="App" onWheel={wheelHandler} />
);
}
As they say in the useRef documentation, refs are useful for non-state instance information:
However, useRef() is useful for more than the ref attribute. It’s handy for keeping any mutable value around similar to how you’d use instance fields in classes.
In my functional component I'm storing state with useState hook. Every time my user gets to the bottom of the page, I want to add content. So I added an EventListener on 'scroll' inside a useEffect hook.
The thing is it gets triggered the first time I reach the bottom, my new content appears and my page length increase so I can scroll further. But then, nothing append.
With console.log I checked if my event was well triggered and it is !
It seems like the callback function given to the event listener is stuck in the past and my setter returns the same state as the first time !
The gameTeasersToShow function has 12 elements, I know that if it worked It would crash if I scrolled down a certain good amount of time because the array would not contain enough elems. It's a test.
function Index ({ gameTeasersToShow }) {
console.log(useScrollToBottomDetec())
const [state, setState] = React.useState([gameTeasersToShow[0], gameTeasersToShow[1], gameTeasersToShow[2]])
function handleScrollEvent (event) {
if (window.innerHeight + window.scrollY >= (document.getElementById('__next').offsetHeight)) {
setState([...state, gameTeasersToShow[state.length]])
}
}
React.useEffect(() => {
window.addEventListener('scroll', handleScrollEvent)
return () => {
window.removeEventListener('scroll', handleScrollEvent)
}
}, [])
return (
<>
{
state.map(item => {
const { title, data } = item
return (
<GameTeasers key={title} title={title} data={data} />
)
})
}
</>
)
}
Can you try that?
function handleScrollEvent (event) {
if (window.innerHeight + window.scrollY >= (document.getElementById('__next').offsetHeight)) {
setState(oldState => [...oldState, gameTeasersToShow[oldState.length]])
}
}
I have a functional component that holds custom viewport values in its state, so it must use event listeners and measure the window size:
const AppWrap = () => {
// custom vw and vh vars
const [vw, setvw] = useState();
const [vh, setvh] = useState();
// gets the inner height/width to act as viewport dimensions (cross-platform benefits)
const setViewportVars = () => {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// can be accessed in scss as vw(n), vh(n) OR in css as --vw * n, --vh * n
document.documentElement.style.setProperty('--vw', `${viewportWidth / 100}px`);
document.documentElement.style.setProperty('--vh', `${viewportHeight / 100}px`);
// can be accessed in child components as vw * n or vh * n
setvw(viewportWidth / 100);
setvh(viewportHeight / 100);
}
// I'd like to run this function *once* when the component is initialized
setViewportVars();
// add listeners
window.addEventListener('resize', setViewportVars);
window.addEventListener('orientationchange', setViewportVars);
window.addEventListener('fullscreenchange', setViewportVars);
return (
<App vw={vw} vh={vh}/>
);
}
The above code produces an error: Too many re-renders. React limits the number of renders to prevent an infinite loop.
I can wrap setViewportVars() in useEffect, but I don't see why this is necessary. My understanding of functional components is that they only run code outside of the return statement once, and that only the JSX would re-render on a state change.
You have to use useEffect and pass empty array as dependencies, so this will only be excecuted once just like componentDidMount:
useEffect(() => {
setViewportVars();
// add listeners
window.addEventListener('resize', setViewportVars);
window.addEventListener('orientationchange', setViewportVars);
window.addEventListener('fullscreenchange', setViewportVars);
}, []);
So in your case what happens is basically you call the function it will update the state, so again component will load again function will call so basically that goes to infinite loop
Solution
you can useEffect, so in useEffect if you pass the second argument which is an array as empty it will called only one time like the componentDidMount
useEffect(() => {
setViewportVars()
}, [])
So if you pass second argument
Passing nothing, like useEffect(() => {}) - it will call every time.
Passing an empty array useEffect(() => {}, []) - it will call one time.
Passing array deps, whenever the array dependencies changes it will execute the code block inside the usEffect.
useEffect(() => {
// some logic
}, [user])
The answer to why you need to useEffect() to prevent the infinite re-render:
<AppWrap> has state {vw} and {vh}. When <AppWrap>is fired, setViewportVars() immediately runs and updates that state. Because you updated the state, setViewportVars() is then fired again (to keep in line with the react one way data flow which updates the state of {vw/vh} and causes a re-firing of AppWrap ...which causes a re-firing of setViewportVars(). At no point here have we allowed the DOM to get painted by the browser, we are just repeating the loop of:
init component > getHeight/Width > updateState > re-render component > getHeight/Width > ...
useEffect behaves differently than a regular render. useEffect fires only after a the DOM has been painted by the browser. Which means that the first cycle would finish (init component > browser paints DOM > useEffect(getHeight/Width) > |if state aka viewsize changed?| > re-render)
For more info, check out Dan Abramov's blog on useEffect
const AppWrap = () => {
// custom vw and vh vars
const [vw, setvw] = useState();
const [vh, setvh] = useState();
// gets the inner height/width to act as viewport dimensions (cross-platform benefits)
const setViewportVars = useCallback(() => {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// can be accessed in scss as vw(n), vh(n) OR in css as --vw * n, --vh * n
document.documentElement.style.setProperty('--vw', `${viewportWidth / 100}px`);
document.documentElement.style.setProperty('--vh', `${viewportHeight / 100}px`);
// can be accessed in child components as vw * n or vh * n
setvw(viewportWidth / 100);
setvh(viewportHeight / 100);
}, []);
useEffect(() => {
window.addEventListener('resize', setViewportVars);
window.addEventListener('orientationchange', setViewportVars);
window.addEventListener('fullscreenchange', setViewportVars);
return () => {
window.removeEventListener('resize', setViewportVars);
window.removeEventListener('orientationchange', setViewportVars);
window.removeEventListener('fullscreenchange', setViewportVars);
}
}, []);
useEffect(() => {
// I'd like to run this function *once* when the component is initialized
setViewportVars();
}, []);
return (
<App vw={vw} vh={vh} />
);
}