How can I access state in an useEffect without re-firing the useEffect?

Viewed 4365

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

5 Answers

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.

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.

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

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>
  );
}

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

Related