50

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.

halfer
  • 19,824
  • 17
  • 99
  • 186
Tony R
  • 11,224
  • 23
  • 76
  • 101
  • 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. – Dinesh Pandiyan Mar 14 '19 at 02:54
  • 1
    @Dinesh In my real code I am not modifying the DOM in the event handler. This was just for demoing the problem. – Tony R Mar 14 '19 at 02:57

3 Answers3

73

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.

Carsten Führmann
  • 3,119
  • 4
  • 26
  • 24
Tony R
  • 11,224
  • 23
  • 76
  • 101
  • 5
    Just noticed this use case is explicitly described in the Drawbacks section of the React Hooks RFC here: https://github.com/reactjs/rfcs/blob/master/text/0068-react-hooks.md#drawbacks – Tony R Mar 15 '19 at 05:39
  • 3
    Actually, the `useState` here, should have the second argument of `[]`, otherwise it's called on every render while is not needed. – mh sattarian Oct 30 '20 at 18:04
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

Dinesh Pandiyan
  • 5,814
  • 2
  • 30
  • 49
  • Thanks for your help. I put your code in a new sandbox here: https://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: https://codesandbox.io/s/6jq3r6vnjw (`App`). Thoughts? – Tony R Mar 14 '19 at 03:25
  • @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'? – Dinesh Pandiyan Mar 14 '19 at 03:41
  • When clicking a button, the value in the event handler should be identical to the current value of the codemirror. – Tony R Mar 14 '19 at 03:44
  • 1
    @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. – Dinesh Pandiyan Mar 14 '19 at 05:16
  • 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.... – Tony R Mar 14 '19 at 05:33
  • 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. – Tony R Mar 14 '19 at 05:42
  • 1
    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. – Dinesh Pandiyan Mar 14 '19 at 06:15
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

Lokii
  • 402
  • 4
  • 14