React hooks. Why doesn't set function change state? - reactjs

const Navbar = () => {
const prevScrollY = React.useRef<number>();
const [isHidden, setIsHidden] = React.useState(false);
React.useEffect(() => {
const onScroll = () => {
const scrolledDown = window.scrollY > prevScrollY.current!;
console.log(`is hidden ${isHidden}`);
if (scrolledDown && !isHidden) {
setIsHidden(true);
console.log(`set hidden true`);
} else if (!scrolledDown && isHidden) {
console.log(`set hidden false. THIS NEVER HAPPENS`);
setIsHidden(false);
}
prevScrollY.current = window.scrollY;
};
console.log("adding listener");
window.addEventListener("scroll", onScroll);
return () => {
window.removeEventListener("scroll", onScroll);
};
}, []);
return isHidden ? null : <div>navbar</div>;
};
Full example
The console.log(`is hidden ${isHidden}`); always prints false, and the setIsHidden(true) always gets triggered but never seems to change the state. Why? Basically the isHidden is never setto false, except after the useState initialization.

Basically what happens is that your useEffect runs only twice on mount and on unmount (and that's apparently intentional), however the unwanted side-effect of this is that the value of isHidden that you're checking against in the onScroll method gets closured at it's initial value (which is false) - forever (until the unmount that is).
You could use functional form of the setter, where it receives the actual value of the state and put all the branching logic inside it. Something like:
setIsHidden(isHidden => { // <- this will be the proper one
const scrolledDown = window.scrollY > prevScrollY.current!;
console.log(`is hidden ${isHidden}`);
if (scrolledDown && !isHidden) {
console.log(`set hidden true`);
return true;
} else if (!scrolledDown && isHidden) {
console.log(`set hidden false. THIS NEVER HAPPENS`);
return false;
} else {
// ...

Related

React scroll event that has access to State

I'm trying to build an infinite scroll component in React (specifically using NextJS). I am having trouble with this feature because when I set a scroll event on the window, it doesn't have access to updated state. How can I write a scroll event that listens to any scrolling on the entire window that also has access to state like router query params?
Here's some code to see what I'm trying to do:
useEffect(() => {
window.addEventListener('scroll', handleScroll);
},[]);
const handleScroll = () => {
const el = infiniteScroll.current;
if (el) {
const rect = el.getBoundingClientRect();
const isVisible =
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <=
(window.innerHeight || document.documentElement.clientHeight) &&
rect.right <=
(window.innerWidth || document.documentElement.clientWidth);
if (isVisible && !isComplete && !isFetching) {
nextPage();
}
}
};
const nextPage = () => {
const params = router.query as any; // <------ these params here never update with state and are locked in to the the values they were at when the component mounted
params.page = params.page
? (parseInt((params as any).page) + 1).toString()
: '1';
router.replace(router, undefined, { scroll: false });
};
The issue is that the router value is locked at the place it was when the component mounted.
I've tried removing the empty array of dependencies for the useEffect at the top, but as you can imagine, this creates multiple scroll listeners and my events fire too many times. I've tried removing the eventListener before adding it every time, but it still fires too many times.
Every example I've found online seems to not need access to state variables, so they write code just like this and it works for them.
Any ideas how I can implement this?
I've tried to use the onScroll event, but it doesn't work unless you have a fixed height on the container so that you can use overflow-y: scroll.
You can use a ref to access and modify your state in the scope of the handleScroll function.
Here is how:
const yourRef = useRef('foo');
useEffect(() => {
const handleScroll = () => {
const value = yourRef.current;
if (value === 'foo') {
yourRef.current = 'bar'
}
};
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, []);
I figured something out that works. Posting in case anyone else is having the same issue.
I created a custom hook called useScrollPosition that sets a listener on the window and updates the scroll position. It looks like this:
const useScrollPosition = () => {
const [scrollPosition, setScrollPosition] = useState(0);
useEffect(() => {
const updatePosition = () => {
setScrollPosition(window.pageYOffset);
};
window.addEventListener('scroll', updatePosition);
updatePosition();
return () => window.removeEventListener('scroll', updatePosition);
}, []);
return scrollPosition;
};
and using that in my component like this:
useEffect(() => {
handleScroll();
}, [scrollPosition]);
allows me to access the current state of the router

setTimeout React: too many re-renders

I'm trying to do something with setTimeout on a switch controller but I don't know what is the problem and I get this error when the code is run, this in fact is a custom hook I use: Error: Too many re-renders. React limits the number of renders to prevent an infinite loop.
import React from 'react';
const useVisibility = () => {
const [visibility, setVisibility] = React.useState(true);
const [firstTime, setFirstTime] = React.useState(true);
let timeOutId;
const removeTimer = () => {
clearTimeout(timeOutId);
timeOutId = 0;
};
React.useEffect(
() => {
document.addEventListener('visibilitychange', (e) => {
if (document.hidden) {
switch (firstTime) {
case true:
setFirstTime(false)
timeOutId = setTimeout(() => {
setVisibility(false);
}, 0);
break;
default:
timeOutId = setTimeout(() => {
setVisibility('closed');
}, 0);
break;
}
} else if (document.isConnected) {
removeTimer();
}
});
},
[visibility]
);
return { visibility, setVisibility };
};
export default useVisibility;
And here is how I'm using it, and also calling a React function inside it:
{
visibility === 'closed' ? <> {cheatingPrevent()}
<Modal onClose={() => setVisibility(true)}
title="test"
text="test." /> </> : null
}
React.useEffect will add an event listener to document every time visibility changes as you have it in the dependency array. For each visibilitychange event, all the duplicate event listeners added will run.
The problem with this is you're calling setVisibility in useEffect callback which updates visibility which in return re-runs useEffect.
You don't need visibility in dependency array of useEffect hook. Pass empty array []

value of useState not being set on initial render

I have the isDesktop boolean that should be set to true or false depending on the sceensize and this works however on initial render it doesn't set to true/false, how can I set this on intial render?
const [isVisible, setIsVisible] = useState(false)
const [isDesktop, setIsDesktop] = useState(window.innerWidth)
console.log(isDesktop)
useEffect(() => {
window.addEventListener("scroll", UpdateScrollPosition);
window.addEventListener("resize", displayWindowSize);
return () => window.removeEventListener("scroll", UpdateScrollPosition);
}, []);
const UpdateScrollPosition = () => {
const scrollPos = window.scrollY
if( scrollPos < 520) {
return setIsVisible(false)
}else if (scrollPos >= 520 && scrollPos <= 1350) {
return setIsVisible(true)
}else if (scrollPos > 1350) {
return setIsVisible(false)
}
}
const displayWindowSize = () => {
let w = window.innerWidth;
if(w >= 500) {
return setIsDesktop(true)
} else {
return setIsDesktop(false)
}
}
window.innerWidth is not a boolean. If you intented to set it to true for a value other than 0 you can do:
const [isDesktop, setIsDesktop] = useState(!!window.innerWidth)
You can also compare it to your breakpoint:
const [isDesktop, setIsDesktop] = useState(window.innerWidth >= 500);
EDIT:
If you want to use isDesktop in the UpdateScrollPosition handler you need to unregister the old handler and register a new handler as a listener, when isDesktop has changed:
const UpdateScrollPosition = useCallback(() => {
// this now depends on isDesktop
console.log(isDesktop);
// .... other code
}, [isDesktop]); // IMPORTANT: add isDesktop here as a dependency
useEffect(() => {
window.addEventListener("scroll", UpdateScrollPosition);
return () => window.removeEventListener("scroll", UpdateScrollPosition);
}, [UpdateScrollPosition]); // IMPORTANT: Add UpdateScrollPosition here as a dependency
What does this?:
useCallback will recreate your handler when the isDesktop dependency changes. Your effect will re-bind the handlers when UpdateScrollPosition changes (which is always the case when isDesktop changes as we added it as a dependency there).

React - useState doesn't update state on setInterval

The state doesn't update the value even though I'm setting it to the oldvalue + 1.
When logging out the values of ltrNewValue or rtlNewValue it's always the same. It's as it's being overwritten by the initial state.
const Row = (props) => {
const [rowState, setRowState] = useState({
renderInterval: null,
value: 0,
});
useEffect(() => {
const interval = setInterval(counterIntervalFunction, props.speed);
setRowState({ ...rowState, renderInterval: interval });
}, []);
const counterIntervalFunction = () => {
if (props.isRunning && props.direction === 'ltr') {
const ltrNewValue = rowState.value === 2 ? 0 : rowState.value + 1;
console.log(ltrNewValue); // always 1
setRowState({ ...rowState, value: ltrNewValue });
console.log(rowState.value); // always 0
props.setRotatingValue(props.index, rowState.value);
} else if (props.isRunning && props.direction === 'rtl') {
const rtlNewValue = rowState.value === 0 ? 2 : rowState.value - 1;
setRowState({ ...rowState, value: rtlNewValue });
props.setRotatingValue(props.index, rowState.value);
} else {
clearCounterInterval();
}
};
My end goal is to increment the rowState.value up to 2 and then setting it to 0 in a infinite loop. How do I do this correctly?
I'm not certain, but it looks like you have a problem with a stale callback here.
useEffect(() => {
const interval = setInterval(counterIntervalFunction, props.speed);
setRowState({ ...rowState, renderInterval: interval });
}, []);
This effect only runs once - When the component is mounted the first time. It uses the counterIntervalFunction function for the interval:
const counterIntervalFunction = () => {
if (props.isRunning && props.direction === 'ltr') {
const ltrNewValue = rowState.value === 2 ? 0 : rowState.value + 1;
console.log(ltrNewValue); // always 1
setRowState({ ...rowState, value: ltrNewValue });
console.log(rowState.value); // always 0
props.setRotatingValue(props.index, rowState.value);
} else if (props.isRunning && props.direction === 'rtl') {
const rtlNewValue = rowState.value === 0 ? 2 : rowState.value - 1;
setRowState({ ...rowState, value: rtlNewValue });
props.setRotatingValue(props.index, rowState.value);
} else {
clearCounterInterval();
}
};
The counterIntervalFunction captures the reference of props and uses it to determine what to display to the user. However, because this function is only run when the component is mounted, the event will only be run with the props passed to the function originally! You can see an example of this happening in this codesandbox.io
This is why you should put all external dependencies inside of the dependencies array:
useEffect(() => {
const interval = setInterval(counterIntervalFunction, props.speed);
setRowState({ ...rowState, renderInterval: interval });
}, [counterIntervalFunction, props.speed, rowState]);
However, this will cause an infinite loop.
Setting state in useEffect is usually considered a bad idea, because it tends to lead to infinite loops - changing the state will cause the component to re-render, causing another effect to be triggered etc.
Looking at your effect loop, what you're actually interested in is capturing a reference to the interval. This interval won't actually have any impact on the component if it changes, so instead of using state, we can use a ref to keep track of it. Refs don't cause re-renders. This also means we can change value to be a stand-alone value.
Because we now no longer depend on rowState, we can remove that from the dependencies array, preventing an infinite render. Now our effect only depends on props.speed and counterIntervalFunction:
const renderInterval = React.useRef();
const [value, setValue] = React.useState(0);
useEffect(() => {
renderInterval.current = setInterval(counterIntervalFunction, props.speed);
return () => {
cancelInterval(renderInterval.current);
};
}, [props.speed, counterIntervalFunction]);
This will work, but because counterIntervalFunction is defined inline, it will be recreated every render, causing the effect to trigger every render. We can stablize it with React.useCallback(). We'll also want to add all the dependencies of this function to ensure that we don't capture stale references to props and we can change setRowState to setValue. Finally, because the interval is cancelled by useEffect, we don't need to call clearCounterInterval anymore.
const counterIntervalFunction = React.useCallback(() => {
if (props.isRunning && props.direction === 'ltr') {
const ltrNewValue = value === 2 ? 0 : value + 1;
setValue(ltrNewValue);
props.setRotatingValue(props.index, ltrNewValue);
} else if (isRunning && props.direction === 'rtl') {
const rtlNewValue = value === 0 ? 2 : value - 1;
setValue(rtlNewValue);
props.setRotatingValue(props.index, rtlNewValue);
}
}, [value, props]);
This can be simplified even further by moving the required props to the arguments:
const counterIntervalFunction = React.useCallback((isRunning, direction, setRotatingValue, index) => {
if (isRunning === false) {
return;
}
if (direction === 'ltr') {
const ltrNewValue = value === 2 ? 0 : value + 1;
setValue(ltrNewValue);
setRotatingValue(index, ltrNewValue);
} else if (props.direction === 'rtl') {
const rtlNewValue = value === 0 ? 2 : value - 1;
setValue(rtlNewValue);
setRotatingValue(index, rtlNewValue);
}
}, [value]);
This could be even simpler if not for setRotatingValue: Right now, you have a component that both maintains it's own state and tells the parent when its state changes. You should be aware that the component state value might not necessarily update when you call it, but setRotatingValue absolutely will. This may lead to a situation where the parent sees a different state than the child does. I would recommend altering the way your data flows such that it's the parent that owns the current value and passes it via props, not the child.
This gives us the following code to finish off:
function Row = (props) => {
const renderInterval = React.useRef();
const [value, setValue] = React.useState(0);
useEffect(() => {
renderInterval.current = setInterval(counterIntervalFunction, props.isRunning, props.direction, props.setRotatingValue, props.index);
return () => {
cancelInterval(renderInterval.current);
};
}, [props, counterIntervalFunction]);
const counterIntervalFunction = React.useCallback((isRunning, direction, setRotatingValue, index) => {
if (isRunning === false) {
return;
}
if (direction === 'ltr') {
const ltrNewValue = value === 2 ? 0 : value + 1;
setValue(ltrNewValue);
setRotatingValue(index, ltrNewValue);
} else if (props.direction === 'rtl') {
const rtlNewValue = value === 0 ? 2 : value - 1;
setValue(rtlNewValue);
setRotatingValue(index, rtlNewValue);
}
}, [value]);
...
}
In this code, you'll notice that we run the effect every time the props or the function changes. This will mean that, unfortunately, the effect will return every loop, because we need to keep a fresh reference to value. This component will always have this problem unless you refactor counterIntervalFunction to not notify the parent with setRotatingValue or for this function to not contain its own state. An alternatively way we could solve this would be using the function form of setValue:
const counterIntervalFunction = React.useCallback((isRunning, direction, setRotatingValue, index) => {
if (isRunning === false) {
return;
}
setValue(value => {
if (direction === 'ltr') {
return value === 2 ? 0 : value + 1;
} else if (direction ==' rtl') {
return value === 0 ? 2 : value - 1;
}
})
}, []);
Because the state update is not guaranteed to run synchronously, there's no way to extract the value from the setValue call and then call the setRotatingValue function, though. :( You could potentially call setRotatingValue inside of the setValue callback but that gives me the heebie geebies.
It's an interval and it may mess things up when you call setState directly by relying on the old state by the name rowState, try this:
setRowState(oldstate=> { ...rowState, value: oldstate.value+1 });

Skip first useEffect when there are multiple useEffects

To restrict useEffect from running on the first render we can do:
const isFirstRun = useRef(true);
useEffect (() => {
if (isFirstRun.current) {
isFirstRun.current = false;
return;
}
console.log("Effect was run");
});
According to example here: https://stackoverflow.com/a/53351556/3102993
But what if my component has multiple useEffects, each of which handle a different useState change? I've tried using the isFirstRun.current logic in the other useEffect but since one returns, the other one still runs on the initial render.
Some context:
const Comp = () => {
const [ amount, setAmount ] = useState(props.Item ? Item.Val : 0);
const [ type, setType ] = useState(props.Item ? Item.Type : "Type1");
useEffect(() => {
props.OnAmountChange(amount);
}, [amount]);
useEffect(() => {
props.OnTypeChange(type);
}, [type]);
return {
<>
// Radio button group for selecting Type
// Input field for setting Amount
</>
}
}
The reason I've used separate useEffects for each is because if I do the following, it doesn't update the amount.
useEffect(() => {
if (amount) {
props.OnAmountChange(amount);
} else if (type) {
props.OnTypeChange(type)
}
}, [amount, type]);
As far as I understand, you need to control the execution of useEffect logic on the first mount and consecutive rerenders. You want to skip the first useEffect. Effects run after the render of the components.
So if you are using this solution:
const isFirstRun = useRef(true);
useEffect (() => {
if (isFirstRun.current) {
isFirstRun.current = false;
return;
}
console.log("Effect was run");
});
useEffect (() => {
// second useEffect
if(!isFirstRun) {
console.log("Effect was run");
}
});
So in this case, once isFirstRun ref is set to false, for all the consecutive effects the value of isFirstRun becomes false and hence all will run.
What you can do is, use something like a useMount custom Hook which can tell you whether it is the first render or a consecutive rerender. Here is the example code:
const {useState} = React
function useMounted() {
const [isMounted, setIsMounted] = useState(false)
React.useEffect(() => {
setIsMounted(true)
}, [])
return isMounted
}
function App() {
const [valueFirst, setValueFirst] = useState(0)
const [valueSecond, setValueSecond] = useState(0)
const isMounted = useMounted()
//1st effect which should run whenever valueFirst change except
//first time
React.useEffect(() => {
if (isMounted) {
console.log("valueFirst ran")
}
}, [valueFirst])
//2nd effect which should run whenever valueFirst change except
//first time
React.useEffect(() => {
if (isMounted) {
console.log("valueSecond ran")
}
}, [valueSecond])
return ( <
div >
<
span > {
valueFirst
} < /span> <
button onClick = {
() => {
setValueFirst((c) => c + 1)
}
} >
Trigger valueFirstEffect < /button> <
span > {
valueSecond
} < /span> <
button onClick = {
() => {
setValueSecond((c) => c + 1)
}
} >
Trigger valueSecondEffect < /button>
<
/div>
)
}
ReactDOM.render( < App / > , document.getElementById("root"))
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.3/umd/react-dom.production.min.js"></script>
<div id="root"></div>
I hope it helps !!
You can use a single useEffect to do both effects in, you just implemented the logic incorrectly.
Your original attempt:
useEffect(() => {
if (amount) {
props.OnAmountChange(amount);
} else if (type) {
props.OnTypeChange(type)
}
}, [amount, type]);
The issue here is the if/elseif, treat these as independent effects instead:
useEffect(() => {
if (amount !== 0) props.onAmountChange(amount);
if (type !== "Type1") props.onTypeChange(type);
}, [amount, type])
In this method if the value is different than the original value, it will call the on change. This has a bug however in that if the user ever switches the value back to the default it won't work. So I would suggest implementing the entire bit of code like this instead:
const Comp = () => {
const [ amount, setAmount ] = useState(null);
const [ type, setType ] = useState(null);
useEffect(() => {
if (amount !== null) {
props.onAmountChange(amount);
} else {
props.onAmountChange(0);
}
}, [amount]);
useEffect(() => {
if (type !== null) {
props.onTypeChange(type);
} else {
props.onTypeChange("Type1");
}
}, [type]);
return (
<>
// Radio button group for selecting Type
// Input field for setting Amount
</>
)
}
By using null as the initial state, you can delay calling the props methods until the user sets a value in the Radio that changes the states.
If you are using multiple useEffects that check for isFirstRun, make sure only the last one (on bottom) is setting isFirstRun to false. React goes through useEffects in order!
creds to #Dror Bar comment from react-hooks: skip first run in useEffect

Resources