React Hooks useState+useEffect+event gives stale state
Asked Answered
P

4

53

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.

Pantomime answered 14/3, 2019 at 2:44 Comment(2)
It doesn't look like it has anything to do with hooks. useEffect executes in async way. And handleEvent method mutates something in the DOM directly. So DOM mutation happens before you get the value from the server. If you do it the React way of rendering based on state value, this won't happen.Eulau
@Dinesh In my real code I am not modifying the DOM in the event handler. This was just for demoing the problem.Pantomime
P
75

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.

Pantomime answered 14/3, 2019 at 7:12 Comment(3)
Just noticed this use case is explicitly described in the Drawbacks section of the React Hooks RFC here: github.com/reactjs/rfcs/blob/master/text/…Pantomime
Actually, the useState here, should have the second argument of [], otherwise it's called on every render while is not needed.Obannon
After experimentation, the second argument of [] is not needed since the value of the ref must update on every render. With that second argument, it will only update after initial render, and then never again.Easter
E
4

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

Eulau answered 14/3, 2019 at 3:13 Comment(7)
Thanks for your help. I put your code in a new sandbox here: codesandbox.io/s/oo159k4w5y (App3). It works to load the server value, however the goal is to get the Codemirror changes too, so if you change the codemirror input, you still get the original server value. I made it work another way, by putting the event handler in the state: codesandbox.io/s/6jq3r6vnjw (App). Thoughts?Pantomime
@TonyR I'm not sure if I understand the desired functionality properly. - What do you mean by 'works'? - What do you mean by 'does not work'?Eulau
When clicking a button, the value in the event handler should be identical to the current value of the codemirror.Pantomime
@TonyR I figured out the issue. It had nothing to do with hooks or React. The handleEvent method was closed with initial state value and passed to EventEmitter and the same value was used all the time. Closures. I have updated the solution.Eulau
This is interesting. However, the point of the event emitter is that I can emit events from a different component than this one. A different component firing the event won't have access to value. Yes, I know there are other ways of doing this (context, Redux, prop drilling). I wanted to get it working with an event emitter pattern....Pantomime
Bear in mind it might not even be a component firing the events. Let's pretend I'm using some event-based library, and I want a component to receive the event, and use its own state to do something (let's assume "something" is React-friendly; it won't modify the DOM). This is more straightforward in Redux, for sure.Pantomime
I think the point is, no matter where you fire the events from, the emitter should include the payload and not rely on data from outside sources. Perhaps, a data store like redux will help because it will allow you to get the store data outside a React component. But the right approach will be to design an implementation where the data is not reliant on outside sources and passed as payload.Eulau
E
0

useCallback should have worked here.

import React, { useState, useEffect, useCallback } from "react";
import PubSub from "pubsub-js";
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
  const handler = (msg, data) => {
    console.info("Value in event handler: ", value);
    document.getElementById("result").innerHTML = value;
  };

  const handleEvent = useCallback(handler, [value]);

  // Get value from server on component creation (mocked)
  useEffect(() => {
    setTimeout(() => {
      setValue("value from server");
    }, 1000);
  }, []);

  // Subscribe to events on component creation
  useEffect(() => {
    PubSub.subscribe("some_event", handleEvent);
    return () => {
      PubSub.unsubscribe(handleEvent);
    };
  }, [handleEvent]);
  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);
        }}
      />
      <button
        onClick={() => {
          ee.emit("some_event");
        }}
      >
        EventEmitter (works)
      </button>
      <button
        onClick={() => {
          PubSub.publish("some_event");
        }}
      >
        PubSub (doesnt work)
      </button>
      <button
        onClick={() => {
          setTimeout(() => handleEvent(), 100);
        }}
      >
        setTimeout (works!)
      </button>
      <div id="result" />
    </React.Fragment>
  );
}

export default App;

Check the codesandbox here https://codesandbox.io/s/react-base-forked-i9ro7

Edva answered 18/11, 2020 at 5:11 Comment(0)
F
0

The state setter in your case setReady has the following variation

setReady((freshState) => {
    /*
    use fresh state here for state
    calculation and return new state
    that setReady will set
    */
})
Fallonfallout answered 12/1 at 0:22 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.