Every chat app works fine until two people use it at once. You send a message, it saves, and the other person sees nothing until they close the screen and open it again. So you add a refresh button. Then a timer that re-fetches every few seconds. Now you're hammering the database, burning reads, and the conversation still feels a half second behind real life.
We hit this building Meme Chat AI, an app where you trade memes back and forth with a bot. Messages were a read you triggered, so the screen only knew what was true the last time you asked. Firestore's real-time listeners are how we stopped asking and let the data come to us.
What onSnapshot actually does
A normal Firestore read with get() gives you the data once, like taking a photo. onSnapshot opens a live connection instead. You hand it a query, and Firestore calls you back the moment anything matching that query changes, with the new state already in hand.
firestore()
.collection('chats')
.doc(chatId)
.collection('messages')
.orderBy('createdAt', 'asc')
.onSnapshot(snapshot => {
const next = snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
setMessages(next);
});
That callback fires on the first load, and then again on every new message, every edit, every delete. You stop writing fetch logic and start reacting to changes.
Wiring it into a component
The listener has to live for as long as the screen does and then go away cleanly. In a component that means opening it in useEffect and returning the unsubscribe function so React tears it down on unmount.
useEffect(() => {
const unsubscribe = firestore()
.collection('chats')
.doc(chatId)
.collection('messages')
.orderBy('createdAt', 'asc')
.onSnapshot(snapshot => {
setMessages(snapshot.docs.map(d => ({ id: d.id, ...d.data() })));
});
return unsubscribe;
}, [chatId]);
onSnapshot hands you back its own unsubscribe function, so returning it from the effect is the whole cleanup story. When chatId changes, React runs the cleanup and opens a fresh listener for the new chat.
The bug you will write at least once
Forget that return unsubscribe and the listener keeps running after the screen is gone. Switch chats a few times and you have four listeners alive at once, all calling setMessages on a component that no longer exists. It shows up as memory creeping, duplicate updates, and the occasional warning about setting state on something unmounted. The fix is always the same line you skipped.
Writes feel instant for free
The part I didn't expect: the sender doesn't wait on the server. Firestore applies your write to the local cache first and fires your own listener immediately, then syncs to the backend in the background. So the person typing sees their message land right away, and the person across the room sees it a moment later when the server confirms. You get optimistic updates without writing a single line of optimistic update code.
What it bought us
No refresh button. No polling timer quietly draining the read quota. Type on one device and it shows up on another without anyone asking the database whether anything changed. New features that touch messages inherit the live behavior automatically, because they read from the same listener instead of rolling their own fetch.
None of this is special to a meme app. It's one listener per screen, opened in an effect and cleaned up on the way out, with the cache making your own writes feel instant. The win was deciding the screen should react to the data instead of going out and asking for it.
If you want to see the live updates in a shipped app, Meme Chat AI is on the App Store.
Top comments (0)