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.
- Here is the full updated code: CodeSandbox
- Also deployed on: Netlify
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.