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