16

I need to add some event handlers that interact with an object outside of React (think Google Maps as an example). Inside this handler function, I want to access some state that I can send through to this external object.

If I pass the state as a dependency to the effect, it works (I can correctly access the state) but the add/remove handler is added every time the state changes.

If I don't pass the state as the dependency, the add/remove handler is added the appropriate amount of times (essentially once), but the state is never updated (or more accurately, the handler can't pull the latest state).

Codepen example:

Perhaps best explained with a Codepen: https://codepen.io/cjke/pen/dyMbMYr?editors=0010

const App = () => {
  const [n, setN] = React.useState(0);

  React.useEffect(() => {
    const os = document.getElementById('outside-react')
    const handleMouseOver = () => {
      // I know innerHTML isn't "react" - this is an example of interacting with an element outside of React
      os.innerHTML = `N=${n}`
    }
    
    console.log('Add handler')
    os.addEventListener('mouseover', handleMouseOver)
    
    return () => {
      console.log('Remove handler')
      os.removeEventListener('mouseover', handleMouseOver)
    }
  }, []) // <-- I can change this to [n] and `n` can be accessed, but add/remove keeps getting invoked

  return (
    <div>
      <button onClick={() => setN(n + 1)}>+</button>
    </div>
  );
};

ReactDOM.render(<App />, document.getElementById("root"));

Summary

If the dep list for the effect is [n] the state is updated, but add/remove handler is added/removed for every state change. If the dep list for the effect is [] the add/remove handler works perfectly but the state is always 0 (the initial state).

I want a mixture of both. Access the state, but only the useEffect once (as if the dependency was []).


Edit: Further clarification

I know how I can solve it with lifecycle methods, but not sure how it can work with Hooks.

If the above were a class component, it would look like:


class App extends React.Component {
  constructor(props) {
    super(props)
    this.state = { n: 0 };
  }

  handleMouseOver = () => {
    const os = document.getElementById("outside-react");
    os.innerHTML = `N=${this.state.n}`;
  };
  
  componentDidMount() {
    console.log("Add handler");
    const os = document.getElementById("outside-react");
    os.addEventListener("mouseover", this.handleMouseOver);
  }

  componentWillUnmount() {
    console.log("Remove handler");
    const os = document.getElementById("outside-react");
    os.removeEventListener("mouseover", handleMouseOver);
  }

  render() {
    const { n } = this.state;

    return (
      <div>
        <strong>Info:</strong> Click button to update N in state, then hover the
        orange box. Open the console to see how frequently the handler is
        added/removed
        <br />
        <button onClick={() => this.setState({ n: n + 1 })}>+</button>
        <br />
        state inside react: {n}
      </div>
    );
  }
}

ReactDOM.render(<App />, document.getElementById("root"));

Noting how the add/remove handler is only added once (obviously ignoring the fact that the App component isn't unmounted), despite the state change.

I'm looking for a way to replicate that with hooks

Chris
  • 54,599
  • 30
  • 149
  • 186
  • use a `ref` instead of a `state`. It's not pretty, but neither is what you're trying to do here. – Thomas Aug 03 '20 at 05:32
  • Thanks Thomas. Can you elaborate on what isn't "pretty" about the above approach? In a class based React approach, it would simply be the `componentDidMount` and `componentWillUnmount` . I'm trying to replicate that behavior of setup and cleanup. – Chris Aug 03 '20 at 05:38
  • In a class based React approach you would have `this.state` and more important `this`, which you don't have in functional components. Here in a functional component you don't have `this.state.n` you have `const n`, which can't change! So `const n` in one render/effect/method is completely seperated from the the `const n` in the next render. You have to rebuild everything for it to point to the new `const` as you saw. And replicate is the right keyword here. Reminds me of people coming from Java or C# and trying to *replicate* their known behaviour of classes in JS. – Thomas Aug 03 '20 at 06:02
  • I agree with most of your statement @Thomas, and I appreciate you taking the time to reply. However, I don't really agree with the last sentiment, this is still the same language & the same framework, and migrating an existing React project to work within the Hooks paradigm shouldn't be thought of unpretty (interacting with external modules is, and always will be, part of working with React). If there is a more hook-friendly approach to working with external code I'd be more than happy to look at that. I _do_ agree with people shoehorning other languages features into JS and vice versa. Thanks – Chris Aug 03 '20 at 06:12
  • 1
    [How to register event with useEffect hooks?](https://stackoverflow.com/questions/55565444/how-to-register-event-with-useeffect-hooks/62005831#62005831) contains a detailed comparison of all different methods to access state inside `useEffect`. Let me know, if that works out for you. – ford04 Aug 05 '20 at 06:17
  • @ford04 your answer on that question is perfect - especially point 3 regarding `useCallback`. Do you want to add an answer here (or reference the other answer) and I will mark it as correct – Chris Aug 05 '20 at 06:21
  • @ford04 your answer deals with updating state in the event handler. This is the other way around, access state in the handler. The 3rd answer is the same as my answer. – Mordechai Aug 05 '20 at 06:28
  • 1
    @Chris gave an answer here to better fit your case of reading state. Hope, that helps – ford04 Aug 05 '20 at 08:32
  • 1
    @Mordechai the third and fourth alternative can both be used for reading state as well. Though indeed this is a difference to the other answer - thanks for mentioning. – ford04 Aug 05 '20 at 08:38

5 Answers5

18

You can use mutable refs to decouple reading current state from effect dependencies:

const [n, setN] = useState(0);
const nRef = useRef(n); // define mutable ref

useEffect(() => { nRef.current = n }) // nRef is updated after each render
useEffect(() => {
  const handleMouseOver = () => {
    os.innerHTML = `N=${nRef.current}` // n always has latest state here
  }
 
  os.addEventListener('mouseover', handleMouseOver)
  return () => { os.removeEventListener('mouseover', handleMouseOver) }
}, []) // no need to set dependencies

const App = () => {
  const [n, setN] = React.useState(0);
  const nRef = React.useRef(n); // define mutable ref

  React.useEffect(() => { nRef.current = n }) // nRef.current is updated after each render
  React.useEffect(() => {
    const os = document.getElementById('outside-react')
    const handleMouseOver = () => { 
      os.innerHTML = `N=${nRef.current}`  // n always has latest state here
    } 

    os.addEventListener('mouseover', handleMouseOver)
    return () => { os.removeEventListener('mouseover', handleMouseOver) }
  }, []) // no need to set dependencies 

  return (
    <div>
      <button onClick={() => setN(prev => prev + 1)}>+</button>
    </div>
  );
};

ReactDOM.render(<App />, document.getElementById("root"));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.13.0/umd/react.production.min.js" integrity="sha256-32Gmw5rBDXyMjg/73FgpukoTZdMrxuYW7tj8adbN8z4=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.0/umd/react-dom.production.min.js" integrity="sha256-bjQ42ac3EN0GqK40pC9gGi/YixvKyZ24qMP/9HiGW7w=" crossorigin="anonymous"></script>
<div id="root"></div>
<div id="outside-react">div</div>
<p>Update counter with + button, then mouseover the div to see recent counter state.</p>

The event listener will be added/removed only once on mounting/unmounting. Current state n can be read inside useEffect without setting it as dependency ([] deps), so there is no re-triggering on changes.

You can think of useRef as mutable instance variables for function components and Hooks. The equivalent in class components would be the this context - that is why this.state.n in handleMouseOver of the class component example always returns latest state and works.

There is a great example by Dan Abramov showcasing above pattern with setInterval. The blog post also illustrates potential problems with useCallback and when an event listener is readded/removed with every state change.

Other useful examples are (global) event handlers like os.addEventListener or integration with external libraries/frameworks at the edges of React.

Note: React docs recommend to use this pattern sparingly. From my point of view, it is a viable alternative in situations, where you just need "the latest state" - independent of React render cycle updates. By using mutable variables, we break out of the function closure scope with potentially stale closure values.

Writing state independently from dependencies has further alternatives - you can take a look at How to register event with useEffect hooks? for more infos.

ford04
  • 66,267
  • 20
  • 199
  • 171
  • 1
    Thanks @ford04 . This, and your answer in the other post are exactly what I was after. I need to wait another 20 hours to apply the reward, but will do so then. Cheers mate – Chris Aug 05 '20 at 09:03
  • 2
    Why `useEffect(() => { nRef.current = n })` and not `useEffect(() => { nRef.current = n }, [n])` ? – Henrique Bruno May 03 '22 at 19:54
2

What's happening is, the function closes on n, but while closures usually see updates to the variable, hook variables are recreated all the time staling the closure.

In hooks based components, the state is assigned a new variable on each render, which the listener function never closed on, and the closure doesn't get updated since you only create the function once on mount (with the empty dependency array). In contrary, in class based components the this stays the same so the closure can see changes.

I don't see constantly adding and removing listeners as an issue. Consider the fact, unless you use useCallback() to create your event handlers (which you should only do with memoized children, otherwise it's premature optimization) in everyday react events, React itself will literally do just this, namely, remove the previous function and set the new function.

Mordechai
  • 15,437
  • 2
  • 41
  • 82
0

The only way to get latest value is to specify it as dependency and here are reasoning behind that

  1. why add or remove called again and again ?

    Every time a dependency changes it re-executes entire function

  2. Why value of n is not updating ?

    every-time a functional component is rendered all assignments will re-happen just like a normal function, so the value of reference object which stored 'n=0' will remain same and on each subsequent render new object will be created which will point to updated value

abhirathore2006
  • 3,317
  • 1
  • 25
  • 29
0

There are some issues in the manner you're trying to solve this particular problem.

If I pass the state as a dependency to the effect, it works (I can correctly access the state) but the add/remove handler is added every time the state changes.

That works because the handler functions are updated acc. to the latest n value when useeffect is called.

If I don't pass the state as the dependency, the add/remove handler is added the appropriate amount of times (essentially once), but the state is never updated (or more accurately, the handler can't pull the latest state).

That's because the handler functions didn't get the current value for n

Using refs here can be an advantage, as the value will persist b/w rerenders. Check this example here: https://codesandbox.io/s/wispy-pond-j80j7?file=/src/App.js

export default function App() {
  const [n, setN] = React.useState(0);
  const nRef = React.useRef(0);
  const outsideReactRef = React.useRef(null);

  const handleMouseOver = React.useCallback(() => {
    outsideReactRef.current.innerHTML = `N=${nRef.current}`;
  }, []);

  React.useEffect(() => {
    outsideReactRef.current = document.getElementById("outside-react");

    console.log("Add handler");
    outsideReactRef.current.addEventListener("mouseover", handleMouseOver);

    return () => {
      console.log("Remove handler");
      outsideReactRef.current.removeEventListener("mouseover", handleMouseOver);
    };
  }, []); // <-- I can change this to [n] and `n` can be accessed, but add/remove keeps getting invoked

  return (
    <div>
      <button
        onClick={() =>
          setN(n => {
            const newN = n + 1;
            nRef.current = newN;
            return newN;
          })
        }
      >
        +
      </button>
    </div>
  );
}
brijesh-pant
  • 967
  • 1
  • 8
  • 16
-1

well you can use window object or global object to assign the variable you want using useEffect like this :

try{
  const App = () => {
  const [n, setN] = React.useState(0);
    
    React.useEffect(()=>{
      window.num = n
    },[n])

  React.useEffect(() => {
      const os = document.getElementById('outside-react')
     const handleMouseOver =() => {
      os.innerHTML = `N=${window.num}`
    }
    console.log('Add handler')
    os.addEventListener('mouseover', handleMouseOver)
    return () => {
      console.log('Remove handler')
      os.removeEventListener('mouseover', handleMouseOver)
    }
  }, []) // <-- I can change this to [n] and it works, but add/remove keeps getting invoked

  return (
    <div>
      <strong>Info:</strong> Click button to update N in state, then hover the orange box. Open the console to see how frequently the handler is added/removed
      <br/>
      <button onClick={() => setN(n + 1)}>+</button>
      <br/>
      state inside react: {n}
    </div>
  );
};

ReactDOM.render(<App />, document.getElementById("root"));
}
catch(error){
console.log(error.message)
}
<div id="outside-react">OUTSIDE REACT - hover to get state</div>
<div id="root"></div>
  <script src="https://unpkg.com/react@16/umd/react.development.js" crossorigin></script>
  <script src="https://unpkg.com/react-dom@16/umd/react-dom.development.js" crossorigin></script>

this will change window.num on n state change

adel
  • 3,436
  • 1
  • 7
  • 20