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.
It seems that several of your questions lately have revolved around trying to prevent React component rerenders. This is fine and well, but don't spend too much time prematurely optimizing. React runs quite well out-of-the-box.
Regarding memo
HOC and optimizing performance, even the docs state outright:
This method only exists as a performance optimization. Do not rely on it to “prevent” a render, as this can lead to bugs.
This means React may still rerender a component if it needs to. I believe mapping the messages
array is one of these cases. When the messages
state updates it's a new array, and so it must be rerendered. React's reconciliation needs to rerender the array and the each element of the array, but it may not need to go any deeper.
You could test this by adding a memoized child component to ChatLine
and watch as even though ChatLine
is wrapped in memo
HOC that it is still rerendered while the memoized child is not.
const Child = memo(({ id }) => {
useEffect(() => {
console.log('Child rendered', id); // <-- doesn't log when messages updates
})
return <>Child: {id}</>;
});
...
const ChatLine = (props) => {
...
useEffect(() => {
console.log("Chatline rendered", line.id); // <-- logs when messages updates
});
return (
<div>
...
<Child id={line.id} />
...
</div>
);
};
export default memo(ChatLine);
The takeaway here should be that you shouldn't prematurely optimize. Tools like memoization and virtualization should only be looked at if you find an actual performance issue and have properly benchmarked/audited performance.
You shouldn't also "over-optimize" either. The React app I develop for a client I work with we did this early on thinking we were saving ourselves time but eventually over time (and as we've gained familiarity with React hooks) we've ripped out most or nearly all of our "optimizations" as they ended up not really saving us much and add more complexity. We eventually found our performance bottlenecks that had more to do with our architecture and component composition than it did with the # of components rendered in lists.
So you were using the useChatService
custom hook in several components but as written each hook was its own instance and providing its own copy of the messages
state and other various callbacks. This is why you had to pass the messages
state as a prop from ChatSection
to Chat
. Here I suggest to move the messages
state and callbacks into a React context so each useChatService
hook "instance" can provide the same context value.
(could probably be renamed since more than just a hook now)
Create a context:
export const ChatServiceContext = createContext({
messages: [],
updateMessage: () => {},
addMessage: () => {},
deleteMessage: () => {}
});
Create a context provider:
getChatAndUnsub
wasn't awaiting anything so there was no reason to declare it async
. Memoize all the callbacks to add, update, and delete messages.
const ChatServiceProvider = ({ children }) => {
const [messages, setMessages] = useState([]);
const getChatAndUnsub = () => {
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 { .... };
});
setMessages(data);
});
return unsubscribe;
};
useEffect(() => {
const unsubscribe = getChatAndUnsub();
return () => {
unsubscribe();
};
}, []);
const updateMessage = useCallback(async (message, id) => {
try {
await updateDoc(doc(db, "messages", id), {
message,
updatedAt: serverTimestamp()
});
} catch (err) {
console.log(err);
}
}, []);
const addMessage = useCallback(async (message) => {
if (!message) {
return;
}
const messageData = { .... };
try {
await addDoc(collection(db, "messages"), messageData);
} catch (e) {
console.log(e);
}
}, []);
const deleteMessage = useCallback(async (idToDelete) => {
if (!idToDelete) {
return;
}
try {
await deleteDoc(doc(db, "messages", idToDelete));
} catch (err) {
console.log(err);
}
}, []);
const keyGen = () => { .... };
return (
<ChatServiceContext.Provider
value={{
messages,
updateMessage,
addMessage,
deleteMessage
}}
>
{children}
</ChatServiceContext.Provider>
);
};
export default ChatServiceProvider;
Create the useChatService
hook:
export const useChatService = () => useContext(ChatServiceContext);
index.js
import ChatServiceProvider from "./hooks/useChatService";
ReactDOM.render(
<React.StrictMode>
<ChatServiceProvider>
<App />
</ChatServiceProvider>
</React.StrictMode>,
document.getElementById("root")
);
Use the useChatService
hook to consume the messages
state.
const ChatSection = () => {
const { messages } = useChatService();
return (
<div>
{messages.length ? <Chat /> : <NoChat />}
<p>ADD A MESSAGE</p>
<ChatInput />
</div>
);
};
export default ChatSection;
Remove the editing state and setters (more on this later). Use the useChatService
hook to consume the messages
state.
const Chat = () => {
const { messages } = useChatService();
return (
<div>
<p>MESSAGES :</p>
{messages.map((line) => (
<ChatLine key={line.id} line={line} />
))}
</div>
);
};
export default Chat;
Move the editing state here. Instead of an editingId
state use a boolean toggle for an edit mode. Encapsulate the edit id in the updateMessage
callback from the context. Manage all the editing state here locally, don't pass the state values and setter as callbacks for another component to call. Note that the EditMessage
component API was updated.
const ChatLine = ({ line }) => {
const [editValue, setEditValue] = useState("");
const [isEditing, setIsEditing] = useState(false);
const { updateMessage, deleteMessage } = useChatService();
return (
<div>
{!isEditing ? (
<>
<span style={{ marginRight: "20px" }}>{line.id}: </span>
<span style={{ marginRight: "20px" }}>[{line.displayName}]</span>
<span style={{ marginRight: "20px" }}>{line.message}</span>
<button
onClick={() => {
setIsEditing(true);
setEditValue(line.message);
}}
>
EDIT
</button>
<button
onClick={() => {
deleteMessage(line.id);
}}
>
DELETE
</button>
</>
) : (
<EditMessage
value={editValue}
onChange={setEditValue}
onSave={() => {
// updating message in DB
updateMessage(editValue, line.id);
setEditValue("");
setIsEditing(false);
}}
onCancel={() => setIsEditing(false)}
/>
)}
</div>
);
};
Here you can use the memo
HOC. You can further hint to React that maybe this component shouldn't rerender if the line id remains equal, but recall that this doesn't completely prevent the component from being rerendered. It's only a hint that maybe React can bail on rerenders.
export default memo(ChatLine, (prev, next) => {
return prev.line.id === next.line.id;
});
Just proxy the props to their respective props of the textarea
and button
. In other words, let ChatLine
maintain the state it needs.
const EditMessage = ({ value, onChange, onSave, onCancel }) => {
return (
<div>
<textarea
onKeyPress={(e) => {
if (e.key === "Enter") {
// prevent textarea default behaviour (line break on Enter)
e.preventDefault();
onSave();
}
}}
onChange={(e) => onChange(e.target.value)}
value={value}
autoFocus
/>
<button type="button" onClick={onCancel}>
CANCEL
</button>
</div>
);
};
export default EditMessage;
Consume addMessage
from the useChatService
hook. I don't think much changed here but including anyway for completeness' sake.
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;
On the component Chat.js
in the .map
avoid to pass complex object as a props to the mapped component (in this case ChatLine.js
line={line}
line
ChatLine.js
props like lineId={line.id}
, lineMessage={line.message}
, lineDisplayName={line.displayName}
...Primitives
to props .. like strings, numbers, bools
This happen because React just compare shallow objects
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With