0

I'm trying to make a Tweets Application with React and Firebase and I have been suffering when trying to get info from more than 1 collection.

So this is the story:

  1. I get the tweets using onSnapshot. All fine here
  2. I need more info from 2 other collections: user_preferences and user_photo, so I use .get() within the onSnapshot
  3. For managing asynchronism, I resolve my 2 promises before returning the tweet data + details data object for my map function.
  4. I made a console.log of my mappedTweet and the values are OKEY. Here I can see the tweet data + details data
  5. But my STATE "tweets" just have an array of undefined objects =(. It shows the right number of rows accoroding to my Tweets collection but rows of undefined data, and not the rows of my mappedTweets objects. Why?

Can anyone shed some light?

useEffect(() => { //------------getting the TWEETS with onSnapshot()------------- const cancelSuscription = firestore .collection('tweets') .onSnapshot((snapshot) => { const promises = []; const tweetsMapped = snapshot.docs.map((doc) => { let tweetAndAuthor; const tweetMappped = { text: doc.data().text, likes: doc.data().likes, email: doc.data().email, created: doc.data().created, uid: doc.data().uid, id: doc.id, }; let authorPreference, authorPhoto; const userPreferencePromise = firestore .collection('user_preferences') .where('uid', '==', tweetMappped.uid) .get(); const userPhotoPromise = firestore .collection('user_photos') .where('id', '==', tweetMappped.uid) .get(); promises.push(userPreferencePromise); promises.push(userPhotoPromise); //------------getting the AUTHOR USER PREFERENCES with .get()------------- userPreferencePromise.then((snapshot2) => { authorPreference = snapshot2.docs.map((doc) => { return { username: doc.data().username, color: doc.data().color, }; }); }); //------------getting the AUTHOR PHOTO with .get()------------- userPhotoPromise.then((snapshot3) => { authorPhoto = snapshot3.docs.map((doc) => { return { photoURL: doc.data().photoURL, }; }); }); Promise.all(promises).then((x) => { return { ...tweetMappped, author: authorPreference[0].username, authorColor: authorPreference[0].color, authorPhoto: authorPhoto[0].photoURL, }; }); }); Promise.all(promises).then((x) => { setTweets(tweetsMapped); }); }); return () => cancelSuscription(); }, []); 

2 Answers 2

1

Well, I made it work by changing the model I was using to retrieve the data from Firebase.

I was using an outer onSnapshot with nested promises (I think I was very near here), but now I'm using nested onSnapshots and now app is behaving as expected.

So this is the new useEffect

useEffect(() => { let cancelUserPrefSuscription, cancelUserPhotoSuscription; // First onSnapshot const cancelTweetSuscription = firestore .collection('tweets') .onSnapshot((tweetSnapshot) => { const list = []; tweetSnapshot.docs.forEach((tweetDoc) => { //Second onSnapshot cancelUserPrefSuscription = firestore .collection('user_preferences') .where('uid', '==', tweetDoc.data().uid) .onSnapshot((userPrefSnapshot) => { userPrefSnapshot.docs.forEach((userPrefDoc) => { //Third onSnapshot cancelUserPhotoSuscription = firestore .collection('user_photos') .where('id', '==', tweetDoc.data().uid) .onSnapshot((userPhotoSnapshot) => { userPhotoSnapshot.docs.forEach((userPhotoDoc) => { //Taking the whole data i need from all snapshots const newData = { id: tweetDoc.id, ...tweetDoc.data(), author: userPrefDoc.data().username, authorColor: userPrefDoc.data().color, authorPhoto: userPhotoDoc.data().photoURL, }; list.push(newData); //Updating my state if (tweetSnapshot.docs.length === list.length) { setTweets(list); } }); }); }); }); }); }); return () => { cancelTweetSuscription(); cancelUserPrefSuscription(); cancelUserPhotoSuscription(); }; }, []); 

Edit: Fix from comments of above code

Author: @samthecodingman

For each call to onSnapshot, you should keep track of its unsubscribe function and keep an array filled with the unsubscribe functions of any nested listeners. When an update is received, unsubscribe each nested listener, clear the array of nested unsubscribe functions and then insert each new nested listener into the array. For each onSnapshot listener attached, a single unsubscribe function should be created that cleans up the listener itself along with any nested listeners.

Note: Instead of using this approach, create a Tweet component that pulls the author's name and photo inside it.

useEffect(() => { // helper function const callIt = (unsub) => unsub(); // First onSnapshot const tweetsNestedCancelListenerCallbacks = []; const tweetsCancelListenerCallback = firestore .collection('tweets') .onSnapshot((tweetSnapshot) => { const newTweets = []; const expectedTweetCount = tweetSnapshot.docs.length; // cancel nested subscriptions tweetsNestedCancelListenerCallbacks.forEach(callIt); // clear the array, but don't lose the reference tweetsNestedCancelListenerCallbacks.length = 0; tweetsNestedCancelListenerCallbacks.push( ...tweetSnapshot.docs .map((tweetDoc) => { // (tweetDoc) => Unsubscribe const tweetId = tweetDoc.id; //Second onSnapshot const userPrefNestedCancelListenerCallbacks = []; const userPrefCancelListenerCallback = firestore .collection('user_preferences') .where('uid', '==', tweetDoc.data().uid) .limitToFirst(1) .onSnapshot((userPrefSnapshot) => { const userPrefDoc = userPrefSnapshot.docs[0]; // cancel nested subscriptions userPrefNestedCancelListenerCallbacks.forEach(callIt); // clear the array, but don't lose the reference userPrefNestedCancelListenerCallbacks.length = 0; //Third onSnapshot const userPhotoCancelListenerCallback = firestore .collection('user_photos') .where('id', '==', tweetDoc.data().uid) .limitToFirst(1) .onSnapshot((userPhotoSnapshot) => { const userPhotoDoc = userPhotoSnapshot.docs[0]; // Taking the whole data I need from all snapshots const newData = { id: tweetId, ...tweetDoc.data(), author: userPrefDoc.data().username, authorColor: userPrefDoc.data().color, authorPhoto: userPhotoDoc.data().photoURL, }; const existingTweetObject = tweets.find(t => t.id === tweetId); if (existingTweetObject) { // merge in changes to existing tweet Object.assign(existingTweetObject, newData); if (expectedTweetCount === newTweets.length) { setTweets([...newTweets]); // force rerender with new info } } else { // fresh tweet tweets.push(newData); if (expectedTweetCount === newTweets.length) { setTweets(newTweets); // trigger initial render } } }); userPrefNestedCancelListenerCallbacks.push(userPhotoCancelListenerCallback); }); // return an Unsubscribe callback for this listener and its nested listeners. return () => { userPrefCancelListenerCallback(); userPrefNestedCancelListenerCallbacks.forEach(callIt); } }) ); }); // return an Unsubscribe callback for this listener and its nested listeners. return () => { tweetsCancelListenerCallback(); tweetsNestedCancelListenerCallbacks.forEach(callIt); }; }, []); 

Edit: Splitting the code in two components

Note: Changed limitToFirst(1) --> limit(1). Splitting the fetch logic in two components simplified the onSnapshot approach!

1.The Parent Component

useEffect(() => { const tweetsUnsubscribeCallback = firestore .collection('tweets') .onSnapshot((tweetSnapshot) => { const mappedtweets = tweetSnapshot.docs.map((tweetDoc) => { return { id: tweetDoc.id, ...tweetDoc.data(), }; }); setTweets(mappedtweets); }); return () => tweetsUnsubscribeCallback(); }, []); 

2.The Child Component: Tweet

useEffect(() => { // Helper Function const unSubscribe = (unsub) => unsub(); //------------getting the AUTHOR USER PREFERENCE const userPrefNestedUnsubscribeCallbacks = []; const userPrefUnsubscribeCallback = firestore .collection('user_preferences') .where('uid', '==', tweet.uid) .limit(1) .onSnapshot((userPrefSnapshot) => { userPrefNestedUnsubscribeCallbacks.forEach(unSubscribe); // cancel nested subscriptions userPrefNestedUnsubscribeCallbacks.length = 0; // clear the array, but don't lose the reference //------------getting the AUTHOR PHOTO const userPhotoUnsubscribeCallback = firestore .collection('user_photos') .where('id', '==', tweet.uid) .limit(1) .onSnapshot((userPhotoSnapshot) => { // Taking the whole data I need from all snapshots setAuthor({ author: userPrefSnapshot.docs[0].data().username, authorColor: userPrefSnapshot.docs[0].data().color, authorPhoto: userPhotoSnapshot.docs[0].data().photoURL, }); }); userPrefNestedUnsubscribeCallbacks.push(userPhotoUnsubscribeCallback); }); return () => { userPrefUnsubscribeCallback(); userPrefNestedUnsubscribeCallbacks.forEach(unSubscribe); }; }, []); 
Sign up to request clarification or add additional context in comments.

4 Comments

cancelUserPrefSuscription and cancelUserPhotoSuscription here would only refer to a single tweet's listeners (the last processed), not all of them in the tweets array.
You're totally right!. So in this approach (with the nested onSnapshots), how would you handle the cleanup function for the listeners?
Instead of using nested onSnapshot calls, create a Tweet component that handles rendering it and also fetching the author's name and photo. This will greatly simplify the above code. But to work with nested listeners, you should keep track of the unsubscribe function for each call to onSnapshot along with an array filled with the unsubscribe functions of any nested listeners. These should then be grouped together into a single unsubscribe function that cancel the listener along with any nested ones. Remember to reset any nested listeners when the parent is updated too.
Great suggestion Sam!. The fetch logic in tweet component simplified the callbacks tracking indeed
0

Basically, you've pushed the promises to your promise array in the state they were before you you processed their data. You want to make use of the Promise.all(docs.map((doc) => Promise<Result>)) pattern here where each document should return a single Promise containing its final result. This then means that the Promise.all will resolve with Result[].

Note: If inside a Promise you are mutating a variable outside of the Promise (e.g. pushing to an array), that is generally a sign that you are doing something wrong and you should rearrange your code.

Here's a quick example of throwing this together:

useEffect(() => { let unsubscribed = false; //------------getting the TWEETS with onSnapshot()------------- const cancelSuscription = firestore .collection('tweets') .onSnapshot((snapshot) => { const tweetsMappedPromises = snapshot.docs.map((doc) => { let tweetAndAuthor; const tweetMappped = { text: doc.data().text, likes: doc.data().likes, email: doc.data().email, created: doc.data().created, uid: doc.data().uid, id: doc.id, }; //------------getting the AUTHOR USER PREFERENCES with .get()------------- const userPreferencePromise = firestore .collection('user_preferences') .where('uid', '==', tweetMappped.uid) .limitToFirst(1) .get() .then((prefDocQuerySnapshot) => { const firstPrefDoc = photoDocQuerySnapshot.docs[0]; const { username, color } = firstPrefDoc.data(); return { username, color }; }); //------------getting the AUTHOR PHOTO with .get()------------- const userPhotoPromise = firestore .collection('user_photos') .where('id', '==', tweetMappped.uid) .limitToFirst(1) .get() .then((photoDocQuerySnapshot) => { const firstPhotoDoc = photoDocQuerySnapshot.docs[0]; return firstPhotoDoc.get("photoURL"); }); //--------------------assemble this result--------------------- return Promises.all([userPreferencePromise, userPhotoPromise]) .then(([authorPreference, authorPhoto]) => { return { ...tweetMappped, author: authorPreference.username, authorColor: authorPreference.color, authorPhoto: authorPhoto.photoURL, }; }); }); Promise.all(tweetsMappedPromises) .then((tweetsMapped) => { if (unsubscribed) return; // ignore result, dealing with out of date data setTweets(tweetsMapped); }) .catch((err) => { if (unsubscribed) return; // ignore result, dealing with out of date data // important! handle errors }); }); return () => { unsubscribed = true; cancelSuscription(); } }, []); 

Notes:

  • You may benefit from using async/await syntax here instead.
  • On new onSnapshot calls, snapshot.docChanges() can be used to make it more efficient and speed up rerenders by only updating the entries that have changed (e.g. added/removed/modified). You would use setTweets(previousTweetsMapped => /* newTweetsMapped */) for this.

1 Comment

I've read every single line and I undestood you perfectly Sam. I'm very grateful because of your guidance here and I'm gonna apply and put in practice your advices in order to handle better my code for asynchronous calls. Happy coder here. Thank you so much @samthecodingman

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.