I have a simple chat app using Firebase v9, with these ponents 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
ponent for every message.
I can click the Edit
button in front of each message, it shows the EditMessage
ponent 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 pleted 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:
";,
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
ponent, but I have no idea how to fix this.
I have a simple chat app using Firebase v9, with these ponents 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
ponent for every message.
I can click the Edit
button in front of each message, it shows the EditMessage
ponent 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 pleted 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./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
ponent, but I have no idea how to fix this.
4 Answers
Reset to default 5 +50Prelude
It seems that several of your questions lately have revolved around trying to prevent React ponent 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 ponent 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 ponent 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 plexity. We eventually found our performance bottlenecks that had more to do with our architecture and ponent position than it did with the # of ponents rendered in lists.
Suggested Solution
So you were using the useChatService
custom hook in several ponents 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.
useChatService
(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);
Provide the Chat service to the app
index.js
import ChatServiceProvider from "./hooks/useChatService";
ReactDOM.render(
<React.StrictMode>
<ChatServiceProvider>
<App />
</ChatServiceProvider>
</React.StrictMode>,
document.getElementById("root")
);
ChatSection
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;
Chat
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;
ChatLine
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 ponent to call. Note that the EditMessage
ponent 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 ponent shouldn't rerender if the line id remains equal, but recall that this doesn't pletely prevent the ponent 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;
});
EditMessage
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;
ChatInput
Consume addMessage
from the useChatService
hook. I don't think much changed here but including anyway for pleteness' 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 ponent Chat.js
in the .map
avoid to pass plex object as a props to the mapped ponent (in this case ChatLine.js
- You are passing as a props
line={line}
- when you update the Message, React do not will know What Message was Changed and will re-render all
line
solution to try
- try pass to
ChatLine.js
props likelineId={line.id}
,lineMessage={line.message}
,lineDisplayName={line.displayName}
... - Pass just
Primitives
to props .. like strings, numbers, bools
This happen because React just pare shallow objects
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;
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.