Problem statement #
React components often use functions as prop values, known as "callback functions", to handle events like clicks or modal state changes. However, issues arise with some component libraries (like "react-materialize") which may incorrectly cache these functions and never update it on following re-renders. So when the time comes to call this callback function (e.g. on a button click), it will use the initial function value.
If an initial function doesn't hold any state in its closure, it's not a big problem. But if it does, you may face a stale state issue like Matt in this StackOverflow question. I also faced the issue using one of internal library components in my project which was also a modal component. I guess it happens due to usage of react's utils for creating modals (mainly createPortal
API) which is the case for react-materialise
Modal component.
The leading response on StackOverflow is correct, but there is a room for improvement which I outlined in my response and will unfold a bit more in this blog post.
Example of the issue #
Let's take as an example a code similar to the StackOverflow question:
export default function App() {
const [isOpen, setIsOpen] = useState(false);
const [state, setState] = useState("");
console.log("State's value on render =", state);
// This function is created each time on every re-render
// So it holds a correct state value
const callbackFn = () => {
console.log("State value inside callbackFn =", state);
setIsOpen(false);
};
const modalOptions = {
onCloseStart: callbackFn,
};
return (
<div className="App">
<Button onClick={() => setIsOpen(true)}>Show modal</Button>
{/**
Modal component from react-materialize
We defined a callback which will be fired on modal close.
`options` is an object which is also created fresh on each re-render.
(!) But due to aggressive caching in Modal component,
it will use initial value of `options` prop each re-render,
so `onCloseStart` will have a "stale" value of `callbackFn`
*/}
<Modal open={isOpen} options={modalOptions}>
<Button onClick={() => setState("new value")}>
Update state's value
</Button>
<Button onClick={() => setIsOpen(false)}>Hide modal</Button>
</Modal>
</div>
);
}
Console output:
// Click on "Show modal"
State's value on render =
// Click on "Update value"
State's value on render = new value
// Click on "Hide modal"
// (!!!) State's value is still empty inside callback
State value inside callbackFn =
State's value on render = new value
Solution #
To address the stale state within callbackFn
, we utilize useRef
to keep the latest function value, ensuring it remains updated across re-renders. Essentially, we will create a stable container (object) with current
property and we will update current
property on each re-render with a fresh instance of callbackFn
:
export default function App() {
const [isOpen, setIsOpen] = useState(false);
const [state, setState] = useState("");
console.log("State's value on render =", state);
const callbackFn = () => {
console.log("In callback state =", state);
setIsOpen(false);
};
const callbackRef = useRef(callbackFn); // <-- the new line
callbackRef.current = callbackFn; // <-- the new line. Store the latest callbackFn on each re-render
const modalOptions = {
onCloseStart: () => callbackRef.current(), // <-- updated line
};
return (
<div className="App">
<Button onClick={() => setIsOpen(true)}>Show modal</Button>
<Modal open={isOpen} options={modalOptions}>
<Button onClick={() => setState("new value")}>
Update state's value
</Button>
<Button onClick={() => setIsOpen(false)}>Hide modal</Button>
</Modal>
</div>
);
}
Here is you can find a working example on CodeSandbox.
***
Since you've made it this far, sharing this article on your favorite social media network would be highly appreciated 💖! For feedback, please ping me on Twitter.
Published