React stale useState value in closure - how to fix?

2.5k Views Asked by At

I want to use a state variable (value) when a modal is closed. However, any changes made to the state variable while the modal is open are not observed in the handler. I don't understand why it does not work.

CodeSandbox

or

Embedded CodeSandbox

  1. Open the modal
  2. Click 'Set value'
  3. Click 'Hide modal'
  4. View console log.

Console output

My understanding is that the element is rendered when the state changes (Creating someClosure foo), but then when the closure function is called after that, the value is still "". It appears to me to be a "stale value in a closure" problem, but I can't see how to fix it.

I have looked at explanations regarding how to use useEffect, but I can't see how they apply here.

Do I have to use a useRef or some other way to get this to work?

[Edit: I have reverted the React version in CodeSandbox, so I hope it will run now. I also implemented the change in the answers below, but it did not help.]

import { useState } from "react";
import { Modal, Button } from "react-materialize";

import "./styles.css";

export default function App() {
  const [isOpen, setIsOpen] = useState(false);
  const [value, setValue] = useState("");

  console.log("Creating someClosure value =", value);

  const someClosure = (argument) => {
    console.log("In someClosure value =", value);
    console.log("In someClosure argument =", argument);
    setIsOpen(false);
  };

  return (
    <div className="App">
      <Button onClick={() => setIsOpen(true)}>Show modal</Button>
      <Modal open={isOpen} options={{ onCloseStart: () => someClosure(value) }}>
        <Button onClick={() => setValue("foo")}>Set value</Button>
        <Button onClick={() => setIsOpen(false)}>Hide modal</Button>
      </Modal>
    </div>
  );
}
4

There are 4 best solutions below

0
Dmitrii Dushkin On BEST ANSWER

I agree with Drew's solution, but it felt for me a bit overcomplicated and also not very future-proof.

I think if we put callback in the ref instead of value it makes thing a bit straightforward and you won't need to worry about other possible stale values.

Example how it might look:

export default function App() {
  const [isOpen, setIsOpen] = useState(false);
  const [value, setValue] = useState("");

  const someClosure = (argument) => {
    console.log("In someClosure value =", value);
    console.log("In someClosure argument =", argument);
    setIsOpen(false);
  };
  const someClosureRef = useRef(someClosure); // <-- new
  someClosureRef.current = someClosure; // <-- new

  return (
    <div className="App">
      <Button onClick={() => setIsOpen(true)}>Show modal</Button>
      <Modal
        open={isOpen}
        options={{ onCloseStart: () => someClosureRef.current() /** <-- updated **/ }}
      >
        <Button onClick={() => setValue("foo")}>Set value</Button>
        <Button onClick={() => setIsOpen(false)}>Hide modal</Button>
      </Modal>
    </div>
  );
}

https://codesandbox.io/s/react-stale-usestate-value-in-closure-how-to-fix-forked-tnj6x2?file=/src/App.js:235-996

enter image description here

2
Aloiso Junior On

This is a hard concept. You are using into your member function a state which evaluates "" at render so regardless state change the function signature still the same before render this is the reason why useEffect and useCallback should be used to trait side effects about state change. But there are a way to ensure get correct state without hooks. Just passing state as a parameter to function, by this approach you will receive the current state at render so just with few changes you achieve this.

At someClosure just create an argument:

const someClosure = (value) => {...}

So into modal component,

options={{ onCloseStart: someClosure(value) }}

Should be what you are looking for

0
Drew Reese On

Issue

The issue here is that you've declared a function during some render cycle and the current values of any variable references are closed over in scope:

const someClosure = () => {
  console.log("In someClosure value =", value); // value -> ""
  setIsOpen(false);
};

This "instance" of the callback is passed as a callback to a component and is invoked at a later point in time:

<Modal open={isOpen} options={{ onCloseStart: someClosure }}>
  <Button onClick={() => setValue("foo")}>Set value</Button>
  <Button onClick={() => setIsOpen(false)}>Hide modal</Button>
</Modal>

When the modal is triggered to close the callback with the now stale closure over the value state value is called.

Solution

Do I have to use a useRef or some other way to get this to work?

Basically yes, use a React ref and a useEffect hook to cache the state value that can be mutated/accessed at any time outside the normal React component lifecycle.

Example:

import { useEffect, useRef, useState } from "react";

...

export default function App() {
  const [isOpen, setIsOpen] = useState(false);
  const [value, setValue] = useState("");

  const valueRef = useRef(value);

  useEffect(() => {
    console.log("Creating someClosure value =", value);
    valueRef.current = value; // <-- cache current value
  }, [value]);


  const someClosure = (argument) => {
    console.log("In someClosure value =", valueRef.current); // <-- access current ref value
    console.log("In someClosure argument =", argument);
    setIsOpen(false);
  };

  return (
    <div className="App">
      <Button onClick={() => setIsOpen(true)}>Show modal</Button>
      <Modal
        open={isOpen}
        options={{
          onCloseStart: () => someClosure(valueRef.current) // <-- access current ref value
        }}
      >
        <Button onClick={() => setValue("foo")}>Set value</Button>
        <Button onClick={() => setIsOpen(false)}>Hide modal</Button>
      </Modal>
    </div>
  );
}

Edit react-stale-usestate-value-in-closure-how-to-fix

enter image description here

0
PeterT On

Although Drew's solution has solved the problem, but this proplem is actually caused by <Model> element which use options to pass callback function which has been resolved at first render. element don't update their options in the later rendering. This should be a bug.

In Drew's solution.

options={{
  onCloseStart: () => someClosure(valueRef.current) // <-- access current ref value
}}

this callback's argument is a ref object which has similar to a pointer. when the ref's current changed, it looks like the value is not stalled.

You can verify by add:

onClick={()=>someClosure(value)} 

in the <Model> element and you will see the value is updated.

This is a interesting problem, so I check the <Model> element source code in Github:

  useEffect(() => {
    const modalRoot = _modalRoot.current;
    if (!_modalInstance.current) {
      _modalInstance.current = M.Modal.init(_modalRef.current, options);
    }

    return () => {
      if (root.contains(modalRoot)) {
        root.removeChild(modalRoot);
      }
      _modalInstance.current.destroy();
    };
    // deep comparing options object
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [safeJSONStringify(options), root]);

You can find that the author use SafeJSONStringify(options) to do a deep comparing which don't care any state's value change.