Search code examples
javascripthtmlreactjstypescriptmobx

React sending outdated state when calling a function after event.preventDefault()


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 }

Solution

  • 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.