Reputation: 4560
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.
or
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>
);
}
Upvotes: 5
Views: 4843
Reputation: 39
There are two prerequisites for causing a stale value when printing it out.
Firstly, the element in the fiber tree must be treated as the same and not considered to unmount and remount.
Secondly, passing someClosure as a function parameter will cause a closure in your case: onCloseStart: () => someClosure(value), where value is captured during initial rendering by React.
If the value is null or empty, then simulating the case of clicking on the setValue button will result in React using its fiber reconciler to determine which elements differ. In this case, since the elements are identical, React does not remount them and therefore, onCloseStart: () => someClosure(value) cannot get updated with the latest value leading to staleness.
you can try this:
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);
};
// Option 1: Using Math.random()
const randomKey = Math.random().toString(36).substring(7);
// Option 2: Using crypto.randomUUID() (more secure, but not supported in older browsers)
// const randomKey = crypto.randomUUID();
return (
<div className="App" key={randomKey}>
<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>
);
}
It will fix the issue, because the element will be remount and the funcion parameter of the Modal can be updated.
Upvotes: 1
Reputation: 26
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>
);
}
Upvotes: 0
Reputation: 1858
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>
);
}
Upvotes: 1
Reputation: 3280
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>
);
}
Upvotes: 2
Reputation: 517
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.
Upvotes: 1
Reputation: 203323
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.
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>
);
}
Upvotes: 3
Reputation: 451
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
Upvotes: 0