Wrong React Hooks Behaviour with Event Listener

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



Leave a reply



Submit