This is a socket.io chat app, where I am trying to add a keyboard shortcut. I am listening for the "keydown" event and calling the button click listener function (which is also throttled) when the specific combination of keys are pressed.
The problem is that when the click listener is being called by the click event of a button it is working as expected but when the listener is triggered from the keydown listener, it doesn't have the updated values of the state due to which it returns without performing any action.
These are my state variables:
const [userID, setUserID] = useState<string>('');
const [messages, setMessages] = useState<TMessage[]>([]);
const [messageInput, setMessageInput] = useState<string>('');
const [room, setRoom] = useState<string | null>(null);
Then I wrote the click event listener:
const handleSubmitClick = throttle(() => {
console.log('userID', userID, 'room', room, 'message', messageInput.trim())
/* When I click the button the listener logs:
"userID 4W_o0nfOpLPknesfAA room 4W_o0nfOpLPknesfAA#NJsYxkxSVVfuQBzrAA message hello there"
But when I use the keyboard shortcut the listener logs:
"userID <empty string> room null message <empty string>"
*/
if (!socket.connected || userID === '' || room === null || messageInput.trim() === '')
return;
const data: TMessage = { userID: socket.id!, message: messageInput.trim(), room: room };
socket.emit(SocketEvents.CHAT_SEND, data);
}, 400) // can run only after 0.4s after the last call
Then there is a Keydown listener:
function keyShortcuts(e: KeyboardEvent) {
if (e.ctrlKey && e.key === 'Enter') {
handleSubmitClick();
}
}
The listener is being added inside a useEffect hook:
useEffect(() => {
if (!socket.connected) {
socket.connect();
}
function onConnect() {
setUserID(socket.id!);
window.addEventListener('keydown', keyShortcuts);
}
socket.on(SocketEvents.CONNECT, onConnect)
return () => {
socket.disconnect()
.off(SocketEvents.CONNECT, onConnect)
}
}, []);
Finally there is a button and textarea in the UI:
<textarea value={messageInput}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setMessageInput(e.target.value)}
disabled={disabled}
/>
<Primary label="Send" subtitle="(ctrl + Enter)"
handleClick={handleSubmitClick} disabled={disabled}
/> {/* react button component */}
The problem you have is due to closures. On initial render, when you are calling a useEffect hook to connect a socket and to add a window event listener - the function that is attached as window keydown event-listener is using the initial values of all the variables that are used inside of this function, those variables are not updated in any way by future rerenders.
And that is not happening with direct button click due to on each rerender - new state variables are set and the function handleSubmitClick
is recreated, it does sees all the recent updates in that case and acts as expected.
One suggestion is to utilize a useRef
hook to hold latest values of everything that you need for keydown event listener and handleSubmitClick
. I found a small utility hook that acts as a useState
one but also returns a ref
variable:
function useStateWithRef<T>(
initialValue: T
): [T, Dispatch<SetStateAction<T>>, React.MutableRefObject<T>] {
const [state, setState] = useState<T>(initialValue);
const ref = useRef<T>(state);
const setStateAndRef: Dispatch<SetStateAction<T>> = (newState) => {
if (typeof newState === "function") {
setState((prevState) => {
const computedState = (newState as (prevState: T) => T)(prevState);
ref.current = computedState;
return computedState;
});
} else {
setState(newState);
ref.current = newState;
}
};
return [state, setStateAndRef, ref];
}
Usage:
const [userID, setUserID, userIDRef] = useStateWithRef<string>("");
const [messages, setMessages, messagesRef] = useStateWithRef<TMessage[]>([]);
const [messageInput, setMessageInput, messageInputRef] =
useStateWithRef<string>("");
const [room, setRoom, roomRef] = useStateWithRef<string | null>(null);
const handleSubmitClick = throttle(() => {
console.log(
"userID",
userIDRef.current,
"room",
room.current,
"message",
messageInput.current.trim()
);
// ...etc
}, 400);
Side note - you are not removing window keydown event listener in your useEffect, fix that please.
Another approach - wrap your functions keyShortcuts
and handleSubmitClick
with useCallback hooks and add a useEffect that will attach/detach keyShortcuts
on state changed:
const lastSocketActionDateMsRef = useRef(0);
const handleSubmitClick = useCallback(() => {
// can run only after 0.4s after the last call
if (lastSocketActionDateMsRef.current + 400 > Date.now()) return;
lastSocketActionDateMsRef.current = Date.now();
console.log("userID", userID, "room", room, "message", messageInput.trim());
if (
!socket.connected ||
userID === "" ||
room === null ||
messageInput.trim() === ""
)
return;
const data = {
userID: socket.id!,
message: messageInput.trim(),
room: room,
};
socket.emit("CHAT_SEND", data);
}, [userID, room, messageInput, socket]);
const keyShortcuts = useCallback(
(e: KeyboardEvent) => {
if (e.ctrlKey && e.key === "Enter") {
handleSubmitClick();
}
},
[handleSubmitClick]
);
useEffect(() => {
window.addEventListener("keydown", keyShortcuts);
return () => {
window.removeEventListener("keydown", keyShortcuts);
};
}, [keyShortcuts]);
Asuming handleSubmitClick has all the checks about socket status, userId, input value - if something is not set it will be a noop. Keydown event listener will be set and unset on each rerender when related states are changed. Just dont forget to remove window.addEventListener from the useEffect that is handling socket initialization.
But, there will be some issue with throttle function in that case - throttle and useCallback do not work nicely with each other, here is some details and workarounds and I'd suggest to remove a throttle and add a const lastSocketActionDateMsRef = useRef(0)
and just check last execution time using this variable.