Problem Summary: setTimeout's are not clearing on mobile devices when using React's useEffect hook. They are, however, clearing on desktop.
Problem Reproduction: https://codepen.io/amliving/pen/QzmPYE.
NB: run on a mobile device to reproduce the problem.
My Question: Why does my solution (explained below) work?
Details:
I'm creating a custom hook to detect idleness. Let's call it useDetectIdle. It dynamically adds and removes event listeners to window from a set of events, which when triggered call a provided callback after a period of time, via setTimeout.
Here is the list of events that will be dynamically added to and then removed from window:
const EVENTS = [
"scroll",
"keydown",
"keypress",
"touchstart",
"touchmove",
"mousedown", /* removing 'mousedown' for mobile devices solves the problem */
];
Here's the useDetectIdle hook. The import piece here is that this hook, when its calling component unmounts, should clear any existing timeout (and remove all event listeners):
const useDetectIdle = (inactivityTimeout, onIdle) => {
const timeoutRef = useRef(null);
const callbackRef = useRef(onIdle);
useEffect(() => {
callbackRef.current = onIdle;
});
const reset = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
const id = setTimeout(callbackRef.current, inactivityTimeout);
timeoutRef.current = id;
};
useEffect(() => {
reset();
const handleEvent = _.throttle(() => {
reset();
}, 1000);
EVENTS.forEach(event => window.addEventListener(event, handleEvent));
return () => {
EVENTS.forEach(event => window.removeEventListener(event, handleEvent));
timeoutRef.current && clearTimeout(timeoutRef.current);
};
}, []);
};
useDetectIdle is called inside components like this:
const Example = () => {
useDetectIdle(5000, () => alert("first"));
return <div className="first">FIRST</div>;
};
On non-touchscreen devices, useDetectIdle works perfectly. But on mobile devices (both iOS and Android), any existing timeout is not cleared when its calling component unmounts. I.e. the callback passed to setTimemout still fires.
My Solution: Through some trial and error, I discovered that removing mousedown from the list of events solves the problem. Does anyone know what's happening under the hood?
Note: this doesn't answer "why your solution works", resp. why it seemed to help, but it points out 2 bugs in your code that I think are the real cause of the behavior. (I.e. your solution does not really work.)
You are handling _.throttle insufficiently - imagine the following scenario:
Your component with hook is mounted.
User triggers one of the events - throttled function is called, i.e. it just internally sets the timeout for 1000ms (throttled callback will be called at the end of 1000ms).
Before the timeout gets hit, you unmount the component. Listeners get removed just fine, but the internal timeout remained and will eventually fire your reset(), even though your component is already unmounted (and from there it will fire the idle callback after another inactivityTimeout ms).
Why the bug was prevalent on mobile was probably tied with what the user had to do to unmount the component on mobile vs desktop, the timing, and what events fired while doing it.
There is also the very tiny possibility that your component's DOM gets unmounted, and because React >= 17.x runs effect cleanup methods asynchronously, a timeout could fire just before your effect cleanup method. I doubt this would be consistently simulated but can be fixed too.
You can fix both issues by switching both effects to useLayoutEffect and introducing local variable unmounted:
const useDetectIdle = (inactivityTimeout, onIdle) => {
const timeoutRef = useRef(null);
const callbackRef = useRef();
useLayoutEffect(() => {
callbackRef.current = onIdle;
});
const reset = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
const id = setTimeout(callbackRef.current, inactivityTimeout);
timeoutRef.current = id;
};
useLayoutEffect(() => {
reset();
let unmounted = false;
const handleEvent = _.throttle(() => {
if (!unmounted) reset();
}, 1000);
EVENTS.forEach(event => window.addEventListener(event, handleEvent));
return () => {
unmounted = true;
EVENTS.forEach(event => window.removeEventListener(event, handleEvent));
timeoutRef.current && clearTimeout(timeoutRef.current);
};
}, []);
};
PS: Idle callback after mount fires after inactivityTimeoutms, whereas subsequent callbacks after inactivityTimeout + 1000ms.
Related
I have a state that tracks the window width:
const [innerWidth, setInnerWidth] = useState(window.innerWidth)
In useEffect, I create a resize eventListener which sets the state to the new width:
useEffect(() => {
document.addEventListener('resize', () => {
setInnerWidth(window.innerWidth)
})
}, [])
Lastly, I have a function test that logs the innerWidth every 5 seconds, with an interval started in useEffect
function test() {
console.log(innerWidth)
}
useEffect(() => {
setInterval(test, 5000)
}, [])
Unfortunately, despite any resize that happen, the test() function keeps on logging the original innerWidth value.
How can I tell react to reload the test function as well?
EDIT:
The perpetual log of the innerWidth was just a simplification of my actual use case. Actually, the timer is shifting an element on the x-axis, and I need to know when it exceeds the width to stop the execution and start again.
Creating and invalidating a loop every time the window changes, like in several answers you've given, temporarily stops the shifting of my element, as the loop gets invalidated. I would like to avoid this.
The useEffect created a closure around the original values, so that's all it ever logs. You'd need the effect to update any time the value changes, by adding it to the dependency array:
useEffect(() => {
setInterval(test, 5000)
}, [innerWidth])
This would of course create a new interval on every state change. So the useEffect should return a function which cancels the interval:
useEffect(() => {
const x = setInterval(test, 5000);
return () => clearInterval(x);
}, [innerWidth])
That way there's only one interval running at any given time.
Though this begs the question... Why? If the goal is to log the value of innerWidth to observe its changes, then why re-log the same value every 5 seconds indefinitely? Skip the test function and the interval entirely and just log the value any time it changes:
useEffect(() => {
console.log(innerWidth);
}, [innerWidth])
Can you change the test function to an anonymous function?
const test = () => {
console.log(innerWidth);
};
Change you useEffect:
useEffect(() => {
window.addEventListener('resize', () => {
setInnerWidth(window.innerWidth);
});
}, [setInnerWidth]);
The solution was wrapping the innerWidth into an object, so that it is passed by reference and it 'updates' in the test function.
const innerWidthWrapper = {width: window.innerWidth}
useEffect(() => {
innerWidthWrapper.width = window.innerWidth
})
}, [])
Edit: Your issue using the interval function is explained in this answer
This code works for me by logging the state variable using the effect hook:
const [innerWidth, setInnerWidth] = useState(window.innerWidth);
useEffect(() => {
const updateWidth = () => {
setInnerWidth(window.innerWidth);
};
window.addEventListener("resize", updateWidth);
}, []);
useEffect(() => {
console.log(innerWidth);
}, [innerWidth]);
I added the eventlistener using the window object.
Sandbox
Attempted to translate an example code from class to functional component and faced the problem.
the target file is in components/Wheel/index.js
Key function that causes problem
const selectItem = () => {
if (selectedItem === null) {
const selectedItem = Math.floor(Math.random() * items.length);
console.log(selectedItem);
setSelectedItem(selectedItem);
} else {
setSelectedItem(null);
let t= setTimeout(() => {
selectItem()
}, 500);
clearTimeout(t);
}
};
First time is normal,
from second time onward,
2 clicks are needed for the wheel to spin.
I had to add clearTimeout() or infinite loop is resulted, but the same does not happen in the original.
Original working example in class
My version in functional component.
MyVersion
Thank you.
What an excellent nuance of hooks you've discovered. When you call selectItem in the timeout, the value of selectedItem that is captured in lexical scope is the last value (not null).
There's two answers, a simple answer and a better working answer.
The simple answer is you can accomplish it be simply separating the functions: https://codesandbox.io/s/spinning-wheel-game-forked-cecpi
It looks like this:
const doSelect = () => {
setSelectedItem(Math.floor(Math.random() * items.length));
};
const selectItem = () => {
if (selectedItem === null) {
doSelect();
} else {
setSelectedItem(null);
setTimeout(doSelect, 500);
}
};
Now, read on if you dare.
The complicated answer fixes the solution for the problem if items.length may change in between the time a timer is set up and it is fired:
https://codesandbox.io/s/spinning-wheel-game-forked-wmeku
Rerendering (i.e. setting state) in a timeout causes complexity - if the component re-rendered in between the timeout, then your callback could've captured "stale" props/state. So there's a lot going on here. I'll try and describe it as best I can:
const [selectedItem, setSelectedItem] = useState(null);
// we're going to use a ref to store our timer
const timer = useRef();
const { items } = props;
// this is just the callback that performs a random select
// you can see it is dependent on items.length from props
const doSelect = useCallback(() => {
setSelectedItem(Math.floor(Math.random() * items.length));
}, [items.length]);
// this is the callback to setup a timeout that we do
// after the user has clicked a "second" time.
// it is dependent on doSelect
const doTimeout = useCallback(() => {
timer.current = setTimeout(() => {
doSelect();
timer.current = null;
}, 500);
}, [doSelect]);
// Here's the tricky thing: if items.length changes in between
// the time we rerender and our timer fires, then the timer callback will have
// captured a stale value for items.length.
// The way we fix this is by using this effect.
// If items.length changes and there is a timer in progress we need to:
// 1. clear it
// 2. run it again
//
// In a perfect world we'd be capturing the amount of time remaining in the
// timer and fire it exactly (which requires another ref)
// feel free to try and implement that!
useEffect(() => {
if (!timer.current) return;
clearTimeout(timer.current);
doTimeout();
// it's safe to ignore this warning because
// we know exactly what the dependencies are here
}, [items.length, doTimeout]);
const selectItem = () => {
if (selectedItem === null) {
doSelect();
} else {
setSelectedItem(null);
doTimeout();
}
};
Currently trying to lock scroll position after a single scroll for one second while I scroll down one section at a time. But I am having some unexpected behaviour.
const [nextSection, setNextSection] = useState('portfolio')
const [isScrollLocked, setIsScrollLocked] = useState(false)
const handleScroll = (section) => {
if (!isScrollLocked) {
console.log('ran', section)
setIsScrollLocked(true)
document.getElementById(section).scrollIntoView()
document.querySelector('body').classList.add('overflow-hidden')
setTimeout(() => {
document.querySelector('body').classList.remove('overflow-hidden')
setIsScrollLocked(false)
}, 1000)
}
}
useEffect(() => {
document.addEventListener('scroll', () => handleScroll(nextSection))
}, [nextSection])
Based on the code above I would think the conditional statement inside handleScroll could only run every second since I change it right away and then only change it back after the settimout but I get a lot of console logs with each scroll. I am updating the nextSection with a scroll spy and parsing it in but despite it being a dependency it does not always seem to update inside the event listener.
Because you added nextSection to your dependency list in your effect it will be called whenever nextSection changes and attach an additional handler to the scroll event. If an effect attaches a handler you need to return a function that detaches it again. Otherwise you will see problems like yours or memory leaks when the component unmounts:
useEffect(() => {
const handler = () => handleScroll(nextSection);
document.addEventListener('scroll', handler);
// cleanup callback, that will be called before the effect runs again
return () => document.removeEventListener('scroll', handler);
}, [nextSection])
I'm trying to debounce the onChange event for a form in my React component. I plan on moving debounceStateUpdate into a static utils function to universalize the debounce time, which is why that extra layer exists rather than just directly using _.debounce.
const ProfileGeneralEditContent = props => {
const debounceStateUpdate = updateFunction => {
return _.debounce(params => updateFunction(params), 700);
};
const FormsyFieldUpdated = debounceStateUpdate((config) => {
console.log("update some things here");
});
return (
<Formsy
onChange={(config) => {
FormsyFieldUpdated.cancel();
FormsyFieldUpdated(config);
}}
onInvalid={() => setValid(false)}
onValid={() => setValid(true)}
>
<div className={'flex justify-start items-start'}>
.
.
. (more jsx)
I would think that when the onChange event fires, the cancel() call would cancel any existing debounce timers that are running and start a new one.
My goal is to debounce inputs from updating state on each key press, so that state will only update after 700ms of no updates. But currently, this code is only delaying each key press' state update by 700 milliseconds, and the state updates for each key press is still happening.
How do I use _.debounce to keep a single running debounce timer for delaying my state update, rather than having 10 timers running at once for each key that is pressed?
I figured it out. I needed to wrap my debounced function definition in useCallback(), because the re-rendering of the component was redefining the debounced function every keypress and thus it would have no knowledge of its previous iterations' running functions.
const ProfileGeneralEditContent = props => {
const debounceStateUpdate = updateFunction => {
return _.debounce(params => updateFunction(params), 700);
};
const FormsyFieldUpdated = useCallback(debounceStateUpdate((config) => {
console.log("update some things here");
}), []);
return (
<Formsy
onChange={(config) => FormsyFieldUpdated(config)}
onInvalid={() => setValid(false)}
onValid={() => setValid(true)}
>
<div className={'flex justify-start items-start'}>
.
.
. (more jsx)
I have a form with a handle function attached to it.
The handle function has a timeout and this is causing some problems.
const timeOut = useRef(null);
const handleSearchChange = (e) => {
// setSearchKey(e.target.value.toLowerCase().trim());
clearTimeout(timeOut.current);
timeOut.current = setTimeout(() => {
setSearchKey(e.target.value.toLowerCase().trim());
}, 500);
}
If I console.log(e.target.value) outside the settimeout function it works fine, when i incorporate the setTimeout function it breaks. Why is this?
I tried simplifying the function to just this :
const handleSearchChange = (e) => {
// setSearchKey(e.target.value.toLowerCase().trim());
console.log(e.target.value)
setTimeout(() => {
// setSearchKey(e.target.value.toLowerCase().trim());
console.log(e.target.value)
}, 500);
}
The issue stays..It logs the first console.log and at the second it breaks.
Event values are cleared by react. You either need to use event.persist to persit event values or store the values from event to be used later
According to react documentation:
SyntheticEvent object will be reused and all properties will be
nullified after the event callback has been invoked. This is for
performance reasons. As such, you cannot access the event in an
asynchronous way.
const handleSearchChange = (e) => {
// setSearchKey(e.target.value.toLowerCase().trim());
clearTimeout(timeOut.current);
const value = e.target.value.toLowerCase().trim();
timeOut.current = setTimeout(() => {
setSearchKey(value);
}, 500);
}
That’s because the e event object in react is a synthetic event object produced by react, not the native event object produced by browser internal.
In order to prevent allocation of new objects all the time, it’s designed to be a reusable object, which means its properties are stripped after emission and re-assigned for next event.
So for your case, because you revisited this object in async callback after emission, it’s been "recycled", making it’s properties outdated. To solve this problem, you can save up beforehand the desired value in the sync event loop, then pass it to async callback.
handleSearchChange = (e) => {
const value = e.target.value.toLowerCase().trim()
clearTimeout(timeOut.current);
timeOut.current = setTimeout(() => {
setSearchKey(value);
}, 500);
}