I have a simple chat app using Firebase v9, with these components from parent to child in this hierarchical order: ChatSection, Chat, ChatLine, EditMessage.
I have a custom hook named useChatService holding the list of messages in state, the hook is called in ChatSection, the hook returns the messages and I pass them from ChatSection in a prop to Chat, then I loop through messages and create a ChatLine component for every message.
I can click the Edit button in front of each message, it shows the EditMessage component so I can edit the text, then when I press "Enter", the function updateMessage gets executed and updates the message in the db, but then every single ChatLine gets rerendered again, which is a problem as the list gets bigger.
EDIT 2: I've completed the code to make a working example with Firebase v9 so you can visualize the rerenders I'm talking about after every (add, edit or delete) of a message. I'm using ReactDevTools Profiler to track rerenders.
ChatSection.js:
import useChatService from "../hooks/useChatService";
import { useEffect } from "react";
import Chat from "./Chat";
import NoChat from "./NoChat";
import ChatInput from "./ChatInput";
const ChatSection = () => {
let unsubscribe;
const { getChatAndUnsub, messages } = useChatService();
useEffect(() => {
const getChat = async () => {
unsubscribe = await getChatAndUnsub();
};
getChat();
return () => {
unsubscribe?.();
};
}, []);
return (
<div>
{messages.length ? <Chat messages={messages} /> : <NoChat />}
<p>ADD A MESSAGE</p>
<ChatInput />
</div>
);
};
export default ChatSection;
Chat.js:
import { useState } from "react";
import ChatLine from "./ChatLine";
import useChatService from "../hooks/useChatService";
const Chat = ({ messages }) => {
const [editValue, setEditValue] = useState("");
const [editingId, setEditingId] = useState(null);
const { updateMessage, deleteMessage } = useChatService();
return (
<div>
<p>MESSAGES :</p>
{messages.map((line) => (
<ChatLine
key={line.id}
line={line}
editValue={line.id === editingId ? editValue : ""}
setEditValue={setEditValue}
editingId={line.id === editingId ? editingId : null}
setEditingId={setEditingId}
updateMessage={updateMessage}
deleteMessage={deleteMessage}
/>
))}
</div>
);
};
export default Chat;
ChatInput:
import { useState } from "react";
import useChatService from "../hooks/useChatService";
const ChatInput = () => {
const [inputValue, setInputValue] = useState("");
const { addMessage } = useChatService();
return (
<textarea
onKeyPress={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addMessage(inputValue);
setInputValue("");
}
}}
placeholder="new message..."
onChange={(e) => {
setInputValue(e.target.value);
}}
value={inputValue}
autoFocus
/>
);
};
export default ChatInput;
ChatLine.js:
import EditMessage from "./EditMessage";
import { memo } from "react";
const ChatLine = ({
line,
editValue,
setEditValue,
editingId,
setEditingId,
updateMessage,
deleteMessage,
}) => {
return (
<div>
{editingId !== line.id ? (
<>
<span style={{ marginRight: "20px" }}>{line.id}: </span>
<span style={{ marginRight: "20px" }}>[{line.displayName}]</span>
<span style={{ marginRight: "20px" }}>{line.message}</span>
<button
onClick={() => {
setEditingId(line.id);
setEditValue(line.message);
}}
>
EDIT
</button>
<button
onClick={() => {
deleteMessage(line.id);
}}
>
DELETE
</button>
</>
) : (
<EditMessage
editValue={editValue}
setEditValue={setEditValue}
setEditingId={setEditingId}
editingId={editingId}
updateMessage={updateMessage}
/>
)}
</div>
);
};
export default memo(ChatLine);
EditMessage.js:
import { memo } from "react";
const EditMessage = ({
editValue,
setEditValue,
editingId,
setEditingId,
updateMessage,
}) => {
return (
<div>
<textarea
onKeyPress={(e) => {
if (e.key === "Enter") {
// prevent textarea default behaviour (line break on Enter)
e.preventDefault();
// updating message in DB
updateMessage(editValue, setEditValue, editingId, setEditingId);
}
}}
onChange={(e) => setEditValue(e.target.value)}
value={editValue}
autoFocus
/>
<button
onClick={() => {
setEditingId(null);
setEditValue(null);
}}
>
CANCEL
</button>
</div>
);
};
export default memo(EditMessage);
useChatService.js:
import { useCallback, useState } from "react";
import {
collection,
onSnapshot,
orderBy,
query,
serverTimestamp,
updateDoc,
doc,
addDoc,
deleteDoc,
} from "firebase/firestore";
import { db } from "../firebase/firebase-config";
const useChatService = () => {
const [messages, setMessages] = useState([]);
/**
* Get Messages
*
* @returns {Promise<Unsubscribe>}
*/
const getChatAndUnsub = async () => {
const q = query(collection(db, "messages"), orderBy("createdAt"));
const unsubscribe = onSnapshot(q, (snapshot) => {
const data = snapshot.docs.map((doc, index) => {
const entry = doc.data();
return {
id: doc.id,
message: entry.message,
createdAt: entry.createdAt,
updatedAt: entry.updatedAt,
uid: entry.uid,
displayName: entry.displayName,
photoURL: entry.photoURL,
};
});
setMessages(data);
});
return unsubscribe;
};
/**
* Memoized using useCallback
*/
const updateMessage = useCallback(
async (editValue, setEditValue, editingId, setEditingId) => {
const message = editValue;
const id = editingId;
// resetting state as soon as we press Enter
setEditValue("");
setEditingId(null);
try {
await updateDoc(doc(db, "messages", id), {
message,
updatedAt: serverTimestamp(),
});
} catch (err) {
console.log(err);
}
},
[]
);
const addMessage = async (inputValue) => {
if (!inputValue) {
return;
}
const message = inputValue;
const messageData = {
// hardcoded photoURL, uid, and displayName for demo purposes
photoURL:
"https://lh3.googleusercontent.com/a/AATXAJwNw_ECd4OhqV0bwAb7l4UqtPYeSrRMpVB7ayxY=s96-c",
uid: keyGen(),
message,
displayName: "John Doe",
createdAt: serverTimestamp(),
updatedAt: null,
};
try {
await addDoc(collection(db, "messages"), messageData);
} catch (e) {
console.log(e);
}
};
/**
* Memoized using useCallback
*/
const deleteMessage = useCallback(async (idToDelete) => {
if (!idToDelete) {
return;
}
try {
await deleteDoc(doc(db, "messages", idToDelete));
} catch (err) {
console.log(err);
}
}, []);
const keyGen = () => {
const s = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
return Array(20)
.join()
.split(",")
.map(function () {
return s.charAt(Math.floor(Math.random() * s.length));
})
.join("");
};
return {
messages,
getChatAndUnsub,
updateMessage,
addMessage,
deleteMessage,
};
};
export default useChatService;
When a message gets updated using updateMessage method, I only need the affected ChatLine to rerender (same for add & delete), not every single ChatLine in the list, while keeping the messages state passed from ChatSection to Chat, I understand that ChatSection & Chat should rerender, but not every ChatLine in the list. (Also ChatLine is memoized)
EDIT 1: I guess the problem is with setMessages(data) in useChatService.js, but I thought React will only rerender the edited line because I already provided the key={line.id} when looping through messages in Chat component, but I have no idea how to fix this.
This is what I think, You are passing Messages in ChatSection and that means that when Messages get updated ChatSection will rerender and all its children will rerender too.
So here is my idea remove Messages from ChatSection and only add it in Chat.
You already using useChatService in Chat so adding Messages there should be better.
Try this and gets back too us if it working.
If still not as you like it to be there is also other way we could fix it.
But you have to create a working example for us so we could have a look and make small changes.
Wrap ChatLine inside React.memo, it will stop multiple rereders.
NOTE: Update areEqual function according to your usecase.
import { useState } from "react";
import ChatLine from "./ChatLine";
import useChatService from "../hooks/useChatService";
function areEqual(prevProps, nextProps) {
/*
return true if passing nextProps to render would return
the same result as passing prevProps to render,
otherwise return false
*/
return prevProps.line === nextProps.line;
}
const ChatLineMemo = React.memo(ChatLine, areEqual);
const Chat = ({ messages }) => {
const [editValue, setEditValue] = useState("");
const [editingId, setEditingId] = useState(null);
const { updateMessage, deleteMessage } = useChatService();
return (
<div>
<p>MESSAGES :</p>
{messages.map((line) => (
<ChatLineMemo
key={line.id}
line={line}
editValue={line.id === editingId ? editValue : ""}
setEditValue={setEditValue}
editingId={line.id === editingId ? editingId : null}
setEditingId={setEditingId}
updateMessage={updateMessage}
deleteMessage={deleteMessage}
/>
))}
</div>
);
};
export default Chat;