Reputation: 13
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:
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();
}, []);
Upvotes: 0
Views: 491
Reputation: 13
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();
};
}, []);
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);
};
}, []);
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);
};
}, []);
Upvotes: 1
Reputation: 26296
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:
async
/await
syntax here instead.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.Upvotes: 0