I have a react + mobx + MaterialUI frontend and I'm facing a problem involving state variables. I have a button that shound trigger a function that converts some state variables into an object and send it other object that will handle the next steps. When I click the button the object is created ok. But when this same function is triggered by the hit enter event the object has empty values (despite when I consoloe.log the value of the state the value logged is correct) Code:
useEffect(() => {
window.addEventListener("keydown", handleKeyPress);
return () => {
window.removeEventListener("keydown", handleKeyPress);
};
}, []);
const handleKeyPress = (e: any) => {
if (e.key === "Enter") {
e.preventDefault();
handleSendPing();
}
};
const handleSendPing = () => {
if (userStore.user) {
var ping: NewPingDto = {
recipient: selectedUser,
locationName: selectedLocation,
pingMessage: selectedPingMessage,
senderId: userStore.user.id,
companyId: userStore.user.companyId,
dateCreated: new Date(),
dateLastModified: new Date(),
isExpired: false,
isUrgent: urgentPing,
pingResponse: "",
isRead: false,
};
console.log("foo", ping);
pingStore.addPing(ping);
}
};
useEffect(() => {
console.log("new selectedUser", selectedUser);
}, [selectedUser]);
All buttons have type="button", so it is not caused by an accidental submit (there is no form element in the page too. the useEffect hook show that selectedUser is not "" Objected printed when I press enter:
{
"recipient": "",
"locationName": "",
"pingMessage": "",
"senderId": "c0a0d5a0-ffaf-4b7e-8f4a-6b6b8b7b8b71",
"companyId": 1,
"dateCreated": "2023-08-21T17:39:25.014Z",
"dateLastModified": "2023-08-21T17:39:25.014Z",
"isExpired": false,
"isUrgent": false,
"pingResponse": "",
"isRead": false }
This is a context issue.
The useEffect
that runs window.addEventListener("keydown", handleKeyPress)
doesn't have any dependencies, so it only runs once, when the component mounts.
At that point, handleKeyPress
references a function that calls another function, handleSendPing
. This other handleSendPing
function's closure, at that point, only sees the initial values for those state variables (selectedUser
, selectedUser
, ...), which is probably undefined
or something similar (e.g. empty string).
One quick fix might be to add handleKeyPress
as dependency to the useEffect
that runs window.addEventListener("keydown", handleKeyPress)
, so that the keydown
event is removed and added again every time handleKeyPress
is re-created (on every render), keeping the state variables in its closure in-sync with the most recent state:
useEffect(() => {
window.addEventListener("keydown", handleKeyPress);
return () => {
window.removeEventListener("keydown", handleKeyPress);
};
}, [handleKeyPress]);
Alternatively, you can use a ref
to address this in two different ways:
Store handleSendPing
in a ref that handleSendPing
can then use to call the most up-to-date handleSendPing
that has the right data in its context.
Give handleSendPing
access to the state using a ref
. This ref
would store all the store variables handleSendPing
needs, and is kept in-sync with the state using a useEffect
:
Using a ref
for the callback:
const handleSendPing = () => {
if (userStore.user) {
var ping: NewPingDto = {
recipient: selectedUser,
locationName: selectedLocation,
pingMessage: selectedPingMessage,
senderId: userStore.user.id,
companyId: userStore.user.companyId,
dateCreated: new Date(),
dateLastModified: new Date(),
isExpired: false,
isUrgent: urgentPing,
pingResponse: "",
isRead: false,
};
pingStore.addPing(ping);
}
};
const handleSendPingRef = useRef(handleSendPing);
useEffect(() => {
handleSendPingRef.current = handleSendPing;
}, [handleSendPing])
useEffect(() => {
// No need to re-create this function on each render,
// so just define it inside the `useEffect` instead:
const handleKeyPress = (e: any) => {
if (e.key === "Enter") {
e.preventDefault();
handleSendPingRef.current();
}
};
window.addEventListener("keydown", handleKeyPress);
return () => {
window.removeEventListener("keydown", handleKeyPress);
};
}, []);
Using a ref
for the callback's context / dependencies:
const sendPingContextRef = useRef({})
useEffect(() => {
sendPingContextRef.current = {
urgentPing,
selectedUser,
selectedLocation,
selectedPingMessage,
user: userStore.user,
};
}, [
urgentPing,
selectedUser,
selectedLocation,
selectedPingMessage,
userStore.user,
])
const handleSendPing = useCallback(() => {
const {
urgentPing,
selectedUser,
selectedLocation,
selectedPingMessage,
user,
} = sendPingContextRef.current;
if (user) {
var ping: NewPingDto = {
recipient: selectedUser,
locationName: selectedLocation,
pingMessage: selectedPingMessage,
senderId: user.id,
companyId: user.companyId,
dateCreated: new Date(),
dateLastModified: new Date(),
isExpired: false,
isUrgent: urgentPing,
pingResponse: "",
isRead: false,
};
pingStore.addPing(ping);
}
}, [])
useEffect(() => {
// No need to re-create this function on each render,
// so just define it inside the `useEffect` instead:
const handleKeyPress = (e: any) => {
if (e.key === "Enter") {
e.preventDefault();
handleSendPing();
}
};
window.addEventListener("keydown", handleKeyPress);
return () => {
window.removeEventListener("keydown", handleKeyPress);
};
}, []);
Also, you should take a look at Rules of Hooks and exhaustive-deps
linting rule.