Состояние React обновляется, но изменения не отражаются в прослушивателе событий, вызываемом программно

Это приложение для чата Socket.io, в котором я пытаюсь добавить сочетание клавиш. Я слушаю событие «keydown» и вызываю функцию прослушивателя нажатия кнопки (которая также регулируется) при нажатии определенной комбинации клавиш.

Проблема в том, что когда прослушиватель кликов вызывается событием нажатия кнопки, он работает должным образом, но когда прослушиватель запускается из прослушивателя нажатия клавиши, он не имеет обновленных значений состояния, из-за которого он возвращается. не совершая никаких действий.

Это мои переменные состояния:

const [userID, setUserID] = useState<string>('');
const [messages, setMessages] = useState<TMessage[]>([]);
const [messageInput, setMessageInput] = useState<string>('');
const [room, setRoom] = useState<string | null>(null);

Затем я написал прослушиватель событий клика:

const handleSubmitClick = throttle(() => {
    console.info('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

Затем есть прослушиватель Keydown:

function keyShortcuts(e: KeyboardEvent) {
    if (e.ctrlKey && e.key === 'Enter') {
      handleSubmitClick();
    }
  }

Слушатель добавляется внутри перехватчика useEffect:

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)
  }
}, []);

Наконец, в пользовательском интерфейсе есть кнопка и текстовая область:

<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 */}

🤔 А знаете ли вы, что...
С React можно работать с данными через HTTP-запросы, используя библиотеки, такие как Axios или Fetch API.


1
53
1

Ответ:

Решено

Проблема, с которой вы столкнулись, связана с замыканиями. При первоначальном рендеринге, когда вы вызываете перехватчик useEffect для подключения сокета и добавления прослушивателя событий окна, функция, которая подключена как прослушиватель событий нажатия клавиши окна, использует начальные значения всех переменных, которые используются внутри этой функции. , эти переменные никак не обновляются при будущих повторных отрисовках.

И этого не происходит при прямом нажатии кнопки из-за каждого повторного рендеринга — устанавливаются новые переменные состояния и функция handleSubmitClick воссоздается, в этом случае она видит все последние обновления и действует так, как ожидалось.

Одно из предложений — использовать крючок useRef для хранения последних значений всего, что вам нужно для прослушивателя событий нажатия клавиш и handleSubmitClick. Я нашел небольшой служебный хук, который действует как useState, но также возвращает переменную ref:

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];
}

Использование:

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.info(
    "userID",
    userIDRef.current,
    "room",
    room.current,
    "message",
    messageInput.current.trim()
  );
  // ...etc
}, 400);

Примечание: вы не удаляете прослушиватель событий нажатия клавиши окна в своем useEffect, исправьте это, пожалуйста.

Другой подход — оберните ваши функции keyShortcuts и handleSubmitClick хуками useCallback и добавьте useEffect, который будет присоединять/отсоединять keyShortcuts при изменении состояния:

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.info("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]);

Предполагая, что handleSubmitClick имеет все проверки состояния сокета, userId, входного значения - если что-то не установлено, это будет ошибка. Прослушиватель событий Keydown будет устанавливаться и отключаться при каждом повторном рендеринге при изменении связанных состояний. Только не забудьте удалить window.addEventListener из useEffect, который обрабатывает инициализацию сокета.

Но в этом случае возникнут некоторые проблемы с функцией дроссельной заслонки - дроссельная заслонка и useCallback не работают друг с другом, вот некоторые подробности и обходные пути, и я бы предложил удалить дроссельную заслонку, добавить const lastSocketActionDateMsRef = useRef(0) и просто проверить время последнего выполнения с использованием этой переменной.