Wrong React hooks behaviour with event listener
This is a common problem for functional components that use the useState
hook. The same concerns are applicable to any callback functions where useState
state is used, e.g. setTimeout
or setInterval
timer functions.
Event handlers are treated differently in CardsProvider
and Card
components.
handleCardClick
and handleButtonClick
used in the CardsProvider
functional component are defined in its scope. There are new functions each time it runs, they refer to cards
state that was obtained at the moment when they were defined. Event handlers are re-registered each time the CardsProvider
component is rendered.
handleCardClick
used in the Card
functional component is received as a prop and registered once on component mount with useEffect
. It's the same function during the entire component lifespan and refers to stale state that was fresh at the time when the handleCardClick
function was defined the first time. handleButtonClick
is received as a prop and re-registered on each Card
render, it's a new function each time and refers to fresh state.
Mutable state
A common approach that addresses this problem is to use useRef
instead of useState
. A ref is basically a recipe that provides a mutable object that can be passed by reference:
const ref = useRef(0);
function eventListener() {
ref.current++;
}
In this case a component should be re-rendered on a state update like it's expected from useState
, refs aren't applicable.
It's possible to keep state updates and mutable state separately but forceUpdate
is considered an anti-pattern in both class and function components (listed for reference only):
const useForceUpdate = () => {
const [, setState] = useState();
return () => setState({});
}
const ref = useRef(0);
const forceUpdate = useForceUpdate();
function eventListener() {
ref.current++;
forceUpdate();
}
State updater function
One solution is to use a state updater function that receives fresh state instead of stale state from the enclosing scope:
function eventListener() {
// doesn't matter how often the listener is registered
setState(freshState => freshState + 1);
}
In this case a state is needed for synchronous side effects like console.log
, a workaround is to return the same state to prevent an update.
function eventListener() {
setState(freshState => {
console.log(freshState);
return freshState;
});
}
useEffect(() => {
// register eventListener once
return () => {
// unregister eventListener once
};
}, []);
This doesn't work well with asynchronous side effects, notably async
functions.
Manual event listener re-registration
Another solution is to re-register the event listener every time, so a callback always gets fresh state from the enclosing scope:
function eventListener() {
console.log(state);
}
useEffect(() => {
// register eventListener on each state update
return () => {
// unregister eventListener
};
}, [state]);
Built-in event handling
Unless the event listener is registered on document
, window
or other event targets that are outside of the scope of the current component, React's own DOM event handling has to be used where possible, this eliminates the need for useEffect
:
<button onClick={eventListener} />
In the last case the event listener can be additionally memoized with useMemo
or useCallback
to prevent unnecessary re-renders when it's passed as a prop:
const eventListener = useCallback(() => {
console.log(state);
}, [state]);
- Previous edition of this answer suggested to use mutable state that was applicable to initial
useState
hook implementation in React 16.7.0-alpha version but isn't workable in final React 16.8 implementation.useState
currently supports only immutable state.*
Using React Hooks, why are my event handlers firing with the incorrect state?
The Problem
Why is
isActive
false?
const mouseMoveHandler = e => {
if(isActive) {
// ...
}
};
(Note for convenience I'm only talking about mouseMoveHandler
, but everything here applies to mouseUpHandler
as well)
When the above code runs, a function instance is created, which pulls in the isActive
variable via function closure. That variable is a constant, so if isActive
is false when the function is defined, then it's always going to be false
as long that function instance exists.
useEffect
also takes a function, and that function has a constant reference to your moveMouseHandler
function instance - so as long as that useEffect callback exists, it references a copy of moveMouseHandler
where isActive
is false.
When isActive
changes, the component rerenders, and a new instance of moveMouseHandler
will be created in which isActive
is true
. However, useEffect
only reruns its function if the dependencies have changed - in this case, the dependencies ([box]
) have not changed, so the useEffect
does not re-run and the version of moveMouseHandler
where isActive
is false is still attached to the window, regardless of the current state.
This is why the "exhaustive-deps" hook is warning you about useEffect
- some of its dependencies can change, without causing the hook to rerun and update those dependencies.
Fixing it
Since the hook indirectly depends on isActive
, you could fix this by adding isActive
to the deps
array for useEffect
:
// Works, but not the best solution
useEffect(() => {
//...
}, [box, isActive])
However, this isn't very clean: if you change mouseMoveHandler
so that it depends on more state, you'll have the same bug, unless you remember to come and add it to the deps
array as well. (Also the linter won't like this)
The useEffect
function indirectly depends on isActive
because it directly depends on mouseMoveHandler
; so instead you can add that to the dependencies:
useEffect(() => {
//...
}, [box, mouseMoveHandler])
With this change, the useEffect will re-run with new versions of mouseMoveHandler
which means it'll respect isActive
. However it's going to run too often - it'll run every time mouseMoveHandler
becomes a new function instance... which is every single render, since a new function is created every render.
We don't really need to create a new function every render, only when isActive
has changed: React provides the useCallback
hook for that use-case. You can define your mouseMoveHandler
as
const mouseMoveHandler = useCallback(e => {
if(isActive) {
// ...
}
}, [isActive])
and now a new function instance is only created when isActive
changes, which will then trigger useEffect
to run at the appropriate moment, and you can change the definition of mouseMoveHandler
(e.g. adding more state) without breaking your useEffect
hook.
This likely still introduces a problem with your useEffect
hook: it's going to rerun every time isActive
changes, which means it'll set the box center point every time isActive
changes, which is probably unwanted. You should split your effect into two separate effects to avoid this issue:
useEffect(() => {
// update box center
}, [box])
useEffect(() => {
// expose window methods
}, [mouseMoveHandler, mouseUpHandler]);
End Result
Ultimately your code should look like this:
const mouseMoveHandler = useCallback(e => {
/* ... */
}, [isActive]);
const mouseUpHandler = useCallback(e => {
/* ... */
}, [isActive]);
useEffect(() => {
/* update box center */
}, [box]);
useEffect(() => {
/* expose callback methods */
}, [mouseUpHandler, mouseMoveHandler])
More info:
Dan Abramov, one of the React authors goes into a lot more detail in his Complete Guide to useEffect blogpost.
Wrong state value in event listener handler after setState
It happened because your handler (handleAppStateChange
) calls only in the first time you render the component [onMount (useEffect(() => {...},[])
)] and it always has the older version of your auth.refresh_token
for solving this issue, you can listen to auth.refresh_token
changes and re-add your EventListener every time refresh_token has a new value (after getProfileData
):
useEffect(() => { // setting my event listener
AppState.addEventListener('change', handleAppStateChange);
return () => {
AppState.removeEventListener('change', handleAppStateChange);
};
}, [auth.refresh_token]);
also you can use React.useCallback
for a memoized callback.
https://reactjs.org/docs/hooks-reference.html#usecallback
for more information: Wrong React hooks behaviour with event listener
How to register event with useEffect hooks?
The best way to go about such scenarios is to see what you are doing in the event handler.
If you are simply setting state
using previous state
, it's best to use the callback pattern and register the event listeners only on initial mount.
If you do not use the callback pattern, the listeners reference along with its lexical scope is being used by the event listener but a new function is created with updated closure on each render; hence in the handler you will not be able to access the updated state
const [userText, setUserText] = useState("");
const handleUserKeyPress = useCallback(event => {
const { key, keyCode } = event;
if(keyCode === 32 || (keyCode >= 65 && keyCode <= 90)){
setUserText(prevUserText => `${prevUserText}${key}`);
}
}, []);
useEffect(() => {
window.addEventListener("keydown", handleUserKeyPress);
return () => {
window.removeEventListener("keydown", handleUserKeyPress);
};
}, [handleUserKeyPress]);
return (
<div>
<h1>Feel free to type!</h1>
<blockquote>{userText}</blockquote>
</div>
);
When should I use useEffect hook instead of event listeners?
The beta docs advise to perform side effects in event handlers when possible, here is quote from docs:
In React, side effects usually belong inside event handlers. Event
handlers are functions that React runs when you perform some
action—for example, when you click a button. Even though event
handlers are defined inside your component, they don’t run during
rendering! So event handlers don’t need to be pure.If you’ve exhausted all other options and can’t find the right event
handler for your side effect, you can still attach it to your returned
JSX with a useEffect call in your component. This tells React to
execute it later, after rendering, when side effects are allowed.
However, this approach should be your last resort.
Also related quote by Dan Abramov:
To sum up, if something happens because a user did something,
useEffect might not be the best tool.On the other hand, if an effect merely synchronizes something (Google
Map coordinates on a widget) to the current state, useEffect is a good
tool. And it can safely over-fire.
Related Topics
Why Does This JavaScript Code Print "Undefined" on the Console
Turning Off Eslint Rule for a Specific Line
How to Copy Static Files to Build Directory with Webpack
Understanding Meteor Publish/Subscribe
Ajax Mailchimp Signup Form Integration
Detecting iOS/Android Operating System
Regular Expression for Password Validation
Create Svg Tag with JavaScript
How to Find Out Which JavaScript Events Fired
Test If Event Handler Is Bound to an Element in Jquery
Optional Chaining in JavaScript
My Very Simple Greasemonkey Script Is Not Running
Loading an Angularjs Controller Dynamically
How to Import Es6 Modules in Content Script for Chrome Extension
Window.Localstorage VS Chrome.Storage.Local