Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

React Hooks useState+useEffect+event gives stale state

I'm trying to use an event emitter with React useEffect and useState, but it always gets the initial state instead of the updated state. It works if I call the event handler directly, even with a setTimeout.

If I pass the value to the useEffect() 2nd argument, it makes it work, however this causes a resubscription to the event emitter every time the value changes (which is triggered off of keystrokes).

What am I doing wrong? I've tried useState, useRef, useReducer, and useCallback, and couldn't get any working.

Here's a reproduction:

import React, { useState, useEffect } from "react";  import { Controlled as CodeMirror } from "react-codemirror2";  import "codemirror/lib/codemirror.css";  import EventEmitter from "events";    let ee = new EventEmitter();    const initialValue = "initial value";    function App(props) {    const [value, setValue] = useState(initialValue);      // Should get the latest value, both after the initial server load, and whenever the Codemirror input changes.    const handleEvent = (msg, data) => {      console.info("Value in event handler: ", value);      // This line is only for demoing the problem. If we wanted to modify the DOM in this event, we would instead call some setState function and rerender in a React-friendly fashion.      document.getElementById("result").innerHTML = value;    };      // Get value from server on component creation (mocked)    useEffect(() => {      setTimeout(() => {        setValue("value from server");      }, 1000);    }, []);      // Subscribe to events on component creation    useEffect(() => {      ee.on("some_event", handleEvent);      return () => {        ee.off(handleEvent);      };    }, []);      return (      <React.Fragment>        <CodeMirror          value={value}          options={{ lineNumbers: true }}          onBeforeChange={(editor, data, newValue) => {            setValue(newValue);          }}        />        {/* Everything below is only for demoing the problem. In reality the event would come from some other source external to this component. */}        <button          onClick={() => {            ee.emit("some_event");          }}        >          EventEmitter (doesnt work)        </button>        <div id="result" />      </React.Fragment>    );  }    export default App;

Here's a code sandbox with the same in App2:

https://codesandbox.io/s/ww2v80ww4l

App component has 3 different implementations - EventEmitter, pubsub-js, and setTimeout. Only setTimeout works.

Edit

To clarify my goal, I simply want the value in handleEvent to match the Codemirror value in all cases. When any button is clicked, the current codemirror value should be displayed. Instead, the initial value is displayed.

like image 242
Tony R Avatar asked Mar 14 '19 02:03

Tony R


People also ask

What is stale state in React?

What is stale state in React? Stale state is a problem that occurs whenever we're trying to update state, often within a closure. A closure is a type of function in JavaScript, where we're using a variable from an outer scope.

How do you prevent a stale closure?

An efficient way to solve stale closures is to correctly set the dependencies of React hooks. Or, in the case of a stale state, use a functional way to update the state. The key takeaway is to try to supply hooks with closures that capture the freshest variables.

What is stale closure React?

Whenever you do that, no new picture is taken. React will just give you the old one to look at. And as we now know, that means we'll have the old photo-bombed variables at our disposal, as opposed to "the latest ones". And that is commonly referred to as a "stale closure".

Does useEffect trigger on state change?

Use the useEffect hook to listen for state changes in React. You can add the state variables you want to track to the hook's dependencies array and the logic in your useEffect hook will run every time the state variables change.


2 Answers

value is stale in the event handler because it gets its value from the closure where it was defined. Unless we re-subscribe a new event handler every time value changes, it will not get the new value.

Solution 1: Make the second argument to the publish effect [value]. This makes the event handler get the correct value, but also causes the effect to run again on every keystroke.

Solution 2: Use a ref to store the latest value in a component instance variable. Then, make an effect which does nothing but update this variable every time value state changes. In the event handler, use the ref, not value.

const [value, setValue] = useState(initialValue); const refValue = useRef(value); useEffect(() => {     refValue.current = value; }); const handleEvent = (msg, data) => {     console.info("Value in event handler: ", refValue.current); }; 

https://reactjs.org/docs/hooks-faq.html#what-can-i-do-if-my-effect-dependencies-change-too-often

Looks like there are some other solutions on that page which might work too. Much thanks to @Dinesh for the assistance.

like image 180
Tony R Avatar answered Sep 30 '22 08:09

Tony R


Updated Answer.

The issue is not with hooks. Initial state value was closed and passed to EventEmitter and was used again and again.

It's not a good idea to use state values directly in handleEvent. Instead we need to pass them as parameters while emitting the event.

import React, { useState, useEffect } from "react"; import { Controlled as CodeMirror } from "react-codemirror2"; import "codemirror/lib/codemirror.css"; import EventEmitter from "events";  let ee = new EventEmitter();  const initialValue = "initial value";  function App(props) {   const [value, setValue] = useState(initialValue);   const [isReady, setReady] = useState(false);    // Should get the latest value   function handleEvent(value, msg, data) {     // Do not use state values in this handler     // the params are closed and are executed in the context of EventEmitter     // pass values as parameters instead     console.info("Value in event handler: ", value);     document.getElementById("result").innerHTML = value;   }    // Get value from server on component creation (mocked)   useEffect(() => {     setTimeout(() => {       setValue("value from server");       setReady(true);     }, 1000);   }, []);    // Subscribe to events on component creation   useEffect(     () => {       if (isReady) {         ee.on("some_event", handleEvent);       }       return () => {         if (!ee.off) return;         ee.off(handleEvent);       };     },     [isReady]   );    function handleClick(e) {     ee.emit("some_event", value);   }    return (     <React.Fragment>       <CodeMirror         value={value}         options={{ lineNumbers: true }}         onBeforeChange={(editor, data, newValue) => {           setValue(newValue);         }}       />       <button onClick={handleClick}>EventEmitter (works now)</button>       <div id="result" />     </React.Fragment>   ); }  export default App; 

Here is a working codesandbox

like image 33
Dinesh Pandiyan Avatar answered Sep 30 '22 08:09

Dinesh Pandiyan