javascriptreactjsreact-memo

Need help preventing all other Friend components re-render when one friend is being updated


I have done some improvements by wrapping all children's props with memo and managed to reduce the re-rendering of all other Friends when a message is being received on the receiver side, and surprisingly the final update after memoizing the props which did the magic was adding useCallback to handleFriendClick, although handleFriendClick is not even needed at that point, yet it still removed the unnecessary re-renders somehow.

Now when I receive a message, I update that friend on the friendslist to display an unread messages count and the other friends do not re-render. However, when I use that handleFriendClick function which made this work after I wrapped it in a useCallback -> it opens the friends message box and this is where all other friends are still being re-rendered -> on handleFriendClick to open, and onClose.

This is the main logic in Home.jsx

const Home = () => {
  const { user, token } = useAuth();
  const [selectedFriends, setSelectedFriends] = useState([]);
  const { messages } = useWebSocket();
  const [friendsList, setFriendsList] = useState([]);

  // Memoize the latest message to avoid unnecessary updates
  const latestMessage = messages.length > 0 ? messages[messages.length - 1] : null;
  // Track sent/received messageIds to avoid duplicates from re-rendering or re-adding messages
  const processedMessagesRef = useRef(new Set());

  // on new message (websocket)
  const handleUpdateFriends = useCallback(
    (message) => {
      // Check if the message has already been added using processedMessagesRef
      if (processedMessagesRef.current.has(message.messageId)) return;
      // Mark this message as handled to prevent re-adding it
      processedMessagesRef.current.add(message.messageId);
      // sender side
      const isSender = message.senderId === user.id;
      if (isSender) {
        setSelectedFriends((prev) => prev.map((f) => (f.id === message.receiverId ? { ...f, storedMessages: [...f.storedMessages, message] } : f)));
        return;
      }
      // receiver side
      const existingFriend = selectedFriends.find((f) => f.id === message.senderId);
      if (existingFriend) {
        setSelectedFriends((prev) => prev.map((f) => (f.id === message.senderId ? { ...f, storedMessages: [...f.storedMessages, message] } : f)));
        if (!existingFriend.isMessageBoxOpen) {
          setFriendsList((prev) => prev.map((f) => (f.id === message.senderId ? { ...f, unreadMessages: (f.unreadMessages || 0) + 1 } : f)));
        }
      } else {
        console.log("receiver side newFriend");
        const friend = friendsList.find((f) => f.id === message.senderId);
        if (friend) {
          setFriendsList((prev) => prev.map((f) => (f.id === message.senderId ? { ...f, storedMessages: [...(f.storedMessages || []), message], unreadMessages: (f.unreadMessages || 0) + 1 } : f)));
        }
      }
    },
    [selectedFriends, friendsList, user.id]
  );

  // on new message (websocket)
  useEffect(() => {
    if (!latestMessage) return;
    handleUpdateFriends(latestMessage);
  }, [latestMessage, handleUpdateFriends]);

  const fetchMessagesForFriend = async (friend) => {
    try {
      const response = await axios.get(`http://localhost:8080/api/chat/messages/${friend.friendshipId}`, {
        params: {
          limit: 100,
        },
      });
      if (response.status === 204) {
        console.log("No messages found.");
      } else if (Array.isArray(response.data)) {
        console.log("response.data", response.data);
        const friendWithMessages = { ...friend, storedMessages: response.data.reverse(), isMessageBoxOpen: true, hasMessageBoxBeenOpenedOnce: true };
        setSelectedFriends((prev) => {
          if (prev.length >= 2) {
            return [prev[1], friendWithMessages];
          }
          return [...prev, friendWithMessages];
        });
      }
    } catch (error) {
      console.error("Failed to fetch messages:", error);
    }
  };

  // on friend click
  const handleFriendClick = useCallback(
    async (friend) => {
      console.log("friend", friend);
      const existingFriend = selectedFriends.find((f) => f.id === friend.id);
      if (existingFriend) {
        if (existingFriend.isMessageBoxOpen) {
          // Case 1: Message box is already open, no need to change anything
          return;
        } else if (existingFriend.hasMessageBoxBeenOpenedOnce) {
          // Case 2: Message box has been opened before but is currently closed,
          // reopens the message box without fetching messages and resets unread messages
          setSelectedFriends((prev) => prev.map((f) => (f.id === friend.id ? { ...f, isMessageBoxOpen: true, unreadMessages: 0 } : f)));
          setFriendsList((prev) => prev.map((f) => (f.id === friend.id ? { ...f, unreadMessages: 0 } : f)));
          return;
        }
      }
      // Case 3: Message box has never been opened before, fetch messages and open the message box by adding a new friend with isMessageBoxOpen: true
      await fetchMessagesForFriend(friend);
      // reset unread messages
      setFriendsList((prev) => prev.map((f) => (f.id === friend.id ? { ...f, unreadMessages: 0 } : f)));
    },
    [selectedFriends]
  );

  return (
    <div>
      {" "}
      <FriendsList friendsList={friendsList} friendsListLoading={friendsListLoading} friendsListError={friendsListError} handleFriendClick={handleFriendClick} /> <MessageBoxList selectedFriends={selectedFriends} setSelectedFriends={setSelectedFriends} />
    </div>
  );
};

This are the memoized FriendsList and Friend components

const FriendsList = memo(({ friendsList, friendsListLoading, friendsListError, handleFriendClick }) => {
  return (
    <aside className="w-[250px] bg-white border-l border-gray-300 p-4">
      <h2 className="text-lg font-semibold mb-4"> Friends </h2> {friendsListError && <p className="text-red-500"> {friendsListError} </p>} {friendsListLoading && <p> Loading... </p>}
      <ul> {friendsList.length > 0 ? friendsList.map((friend) => <Friend key={friend.id} friend={friend} handleFriendClick={handleFriendClick} />) : <li className="py-2 text-gray-500">No friends found</li>} </ul>{" "}
    </aside>
  );
});
let renderCount = 0;

const Friend = memo(({ friend, handleFriendClick }) => {
  console.log("Friend rendered", renderCount++);
  console.log("friend username", friend.username, friend);
  const onHandleFriendClick = useCallback(
    async (friend) => {
      try {
        // call the parent function (Home->FriendList->Friend passed through props) to update the messages state on "Home"
        handleFriendClick(friend);
      } catch (error) {
        console.log("Failed to fetch messages:", error);
      }
    },
    [handleFriendClick]
  );

  return (
    <li onClick={() => onHandleFriendClick(friend)} key={friend.id} className="flex py-2 border-b border-gray-200 cursor-pointer hover:bg-gray-200 rounded-md">
      <div className="px-2"> {friend.username.length > 20 ? friend.username.slice(0, 20) + "..." : friend.username} </div> {friend.unreadMessages > 0 && <div className="bg-red-500 text-white rounded-full px-2 ml-2"> {friend.unreadMessages} </div>}{" "}
    </li>
  );
});

The MessageBoxList and MessageBox components are memoized in the same manner. Can you help prevent re-rendering all friends when one friend is being clicked on the friends list and then closed? Also, need advice if my overall approach is generally recommended because I am not fully aware of what I am doing and how to approach this.


Solution

  • when I use that handleFriendClick function [...] all other friends are still being re-rendered

    The immediate cause is that handleFriendClick changes the value of selectedFriends, which is in its dependency array. Hence the useCallback hook returns a new function.

    A simple "immediate" solution consists in such cases in leveraging the setSelectedFriends state setter updater function mode (which you already use somehow) to access that selectedFriends state as the previous value, instead of reading it directly.

    We can move a lot of logic inside that updater function.

    That way, we no longer need to have it in the dependency array:

    const handleFriendClick = useCallback(
      async (friend) => {
        // Use a flag to know if more action is needed
        let needFetch = false;
    
        // Leverage the state setter updater function mode
        // to get access to the state (previous) value,
        // instead of reading it directly
        setSelectedFriends((previousSelectedFriends) => {
          // Some logic can be moved inside the updater function
          const existingFriend = previousSelectedFriends.find((f) => f.id === friend.id);
          if (existingFriend) {
            if (existingFriend.isMessageBoxOpen) {
              // Case 1: Message box is already open, no need to change anything
              // Now make sure to return the same state value
              // for the updater function
              return previousSelectedFriends;
            } else if (existingFriend.hasMessageBoxBeenOpenedOnce) {
              // Case 2: Message box has been opened before but is currently closed,
              // reopens the message box without fetching messages and resets unread messages
              setFriendsList((prev) => prev.map((f) => (f.id === friend.id ? { ...f, unreadMessages: 0 } : f)));
              // Now return the ne state value
              // for the updater function
              return previousSelectedFriends.map((f) => (f.id === friend.id ? { ...f, isMessageBoxOpen: true, unreadMessages: 0 } : f));
            }
          }
          // Case 3: Message box has never been opened before, fetch messages and open the message box by adding a new friend with isMessageBoxOpen: true
          needFetch = true;
          // Now make sure to return the same state value
          // for the updater function
          return previousSelectedFriends;
        }); // end of setSelectedFriends
    
        // More actions to perform,
        // which do not need the selectedFriends value
        if (needFetch) {
          await fetchMessagesForFriend(friend);
          // reset unread messages
          setFriendsList((prev) => prev.map((f) => (f.id === friend.id ? { ...f, unreadMessages: 0 } : f)));
        }
      },
      [] // With this, the useCallback no longer depends on selectedFriends state directly!
    );
    

    That being said, unless you actually see a performance issue, there is usually no harm having many Components "re-render" (what you observe when console.log in the body of the Components is executed).

    This is a normal React behavior, as it re-evaluates its Virtual DOM. But it does not trigger an actual DOM "re-render" (layout and painting, which are the usual performance bottlenecks of browsers), as long as the JSX output is the same.

    This should be your case: even if the handleFriendClick callback function changes, it is not used to generate some JSX content, but is (indirectly) attached only as an event listener.

    React actually manages all those event listeners globally through event delegation, and does not bother "re-rendering" the actual DOM when only these listeners change.

    Sometimes, it may happen that even the React re-evaluation of its Virtual DOM becomes heavy (typically when the Components still perform a lot of computations, without using useMemo). If for some reasons useMemo is still not enough, then indeed we may need React.memo to further avoid React re-renders.