React stale useState value in closure - how to fix?
Asked Answered
F

6

5

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>
  );
}
Freudian answered 13/9, 2022 at 4:0 Comment(1)
@DrewReese I think it was due to trying a different version of React. I have restored version 18.2.0 and the embed link works now. You could try again.Freudian
K
0

PLEASE read this answer, there is a simple and future-proof way of getting around this problem.

The closure traps the old value because it cannot guarantee that when it is run, it can access those variables in scope (because you could pass that closure to another component to run, where the state doesn't exist). The solution is to use an overloaded version of the setState method as your update function, that provides the old value to you itself. This is what is would look like for your code:

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) => {
    // NEW CODE -------------------------------------------------
    setIsOpen((oldVal) => {
      console.log("In someClosure value =", oldVal);
      console.log("In someClosure argument =", argument);
      return false;
    });
    // OLD CODE HERE --------------------------------------------
    // 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>
  );
}
Kenna answered 22/10 at 18:17 Comment(0)
M
3

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

Majesty answered 14/9, 2022 at 22:1 Comment(0)
A
2

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

Antiicer answered 12/10, 2023 at 17:3 Comment(1)
Works like a charm. This really saved my day.Bucksaw
C
1

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.

Convenance answered 15/9, 2022 at 0:9 Comment(0)
E
1

This is old, but I wanted to offer an approach that doesn't use a ref as an escape hatch: just don't pass the variable into the closure in the first place. Instead, communicate the change of state to the surrounding component by changing state, and use useEffect to handle the change.

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

import "./styles.css";

export default function App() {
  const [isOpen, setIsOpen] = useState(false);
  const [isClosing, setIsClosing] = 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);
  };

  useEffect(() => {
    if (isClosing) {
      // do stuff you would have done in the closure, all
      // values are freshly scoped
      someClosure(value)

      // you'll probably want to do this with `onCloseEnd` or
      // something, but for our purposes you can do it here
      isClosing(false)
    }
  }, [isClosing])
    

  return (
    <div className="App">
      <Button onClick={() => setIsOpen(true)}>Show modal</Button>
      <Modal open={isOpen} options={{ onCloseStart: () => isClosing(true) }}>
        <Button onClick={() => setValue("foo")}>Set value</Button>
        <Button onClick={() => setIsOpen(false)}>Hide modal</Button>
      </Modal>
    </div>
  );
}
Everything answered 28/6 at 3:13 Comment(0)
S
0

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

Senaidasenalda answered 13/9, 2022 at 4:58 Comment(2)
Aloiso Junior I have tried your suggestion (see CodeSandbox), but it suffers from the same problem.Freudian
This solution would result in someClosure being immediately invoked when App renders and the return value passed to the onCloseStart property. I suspect this would cause issues if the return value of someClosure wasn't another function to be called.Majesty
K
0

PLEASE read this answer, there is a simple and future-proof way of getting around this problem.

The closure traps the old value because it cannot guarantee that when it is run, it can access those variables in scope (because you could pass that closure to another component to run, where the state doesn't exist). The solution is to use an overloaded version of the setState method as your update function, that provides the old value to you itself. This is what is would look like for your code:

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) => {
    // NEW CODE -------------------------------------------------
    setIsOpen((oldVal) => {
      console.log("In someClosure value =", oldVal);
      console.log("In someClosure argument =", argument);
      return false;
    });
    // OLD CODE HERE --------------------------------------------
    // 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>
  );
}
Kenna answered 22/10 at 18:17 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.