How do I go about this? I can't figure it out from anything else I can find on here.
Background
I have collections in the Firestore for posts
and users
. The information is going to be rendered out in to a Posts components displaying all of the existing posts on the dashboard.
Users Collection
users
holds an avatar
property which stores an image URL. The doc id for each user is just their username as these are unique.
Posts Collection
posts
have an author
property which is exactly the same as the username
/doc.id
The Aim
When iterating through the posts
I want to push them to an array and store their id
and the rest of the post
data. I also need to relate this to the users
collection and with each iteration, find the avatar
of the user
that matches the post author
.
Things I've already tried
I have tried using async/await
within the forEach
loop, using the post.author
value to get the correct user document
and pulling the avatar
.
Posts
component code
import { useEffect, useState } from "react"
import { Link } from "react-router-dom"
import { collection, onSnapshot /*doc, getDoc*/ } from "firebase/firestore"
import { db } from "lib/firebase"
import AllPostsSkeleton from "components/Skeletons/AllPostsSkeleton"
import Tags from "components/Tags"
import defaultAvatar from "assets/images/avatar_placeholder.png"
const Posts = () => {
const [loading, setLoading] = useState(true)
const [posts, setPosts] = useState(null)
useEffect(() => {
const unsubscribe = onSnapshot(
collection(db, "posts"),
(docs) => {
let postsArray = []
docs.forEach((post) => {
// const docRef = doc(db, "users", post.author)
// const docSnap = await getDoc(docRef)
postsArray.push({
id: post.id,
// avatar: docSnap.data().avatar,
...post.data(),
})
})
setPosts(postsArray)
},
(error) => {
console.log(error)
}
)
setLoading(false)
return () => unsubscribe()
}, [])
if (loading) return <AllPostsSkeleton />
if (!posts) return <div className="no-posts">No posts to show</div>
const RenderPosts = () => {
const sortedPosts = posts.sort((a, b) => {
return new Date(b.date.seconds) - new Date(a.date.seconds)
})
return sortedPosts.map(
({ id, author, slug, content, tags, comment_count, avatar }) => (
<article className="post-preview media" key={id}>
<figure className="media-left">
<p className="comment-avatar image is-96x96">
<img src={avatar || defaultAvatar} alt={content.title} />
</p>
</figure>
<div className="media-content">
<div className="content">
<header className="post-header">
<h2 className="title is-3">
<Link to={`/user/${author}/posts/${slug}`}>
{content.title}
</Link>
</h2>
<div className="tags">
<Tags data={tags} />
</div>
</header>
<p className="post-excerpt">{content.excerpt}</p>
<footer className="post-footer">
Posted by
<Link to={`/user/${author}`} className="capitalise">
{author}
</Link>
| <Link to={`/user/${author}/posts/${slug}`}>View Post</Link>
</footer>
<div className="content">
<Link to={`/user/${author}/posts/${slug}#comments`}>
Comments ({comment_count})
</Link>
</div>
</div>
</div>
</article>
)
)
}
return (
<div className="posts-list">
<RenderPosts />
</div>
)
}
export default Posts
CodePudding user response:
When working with Promises, especially with the Firebase SDK, you will see the Promise.all(docs.map((doc) => Promise<Result>))
being used often so that the Promises are correctly chained together.
Even if you chain the Promises properly, you introduce a new issue where if a snapshot is received while you are still processing the previous snapshot of documents, you will now have two sets of data fighting each other to call setPosts
. To solve this, each time the snapshot listener fires again, you should "cancel" the Promise chain fired off by the previous execution of the listener.
function getAvatarsForEachPost(postDocSnaps) {
return Promise.all(
postDocSnaps.map((postDocSnap) => {
const postData = postDocSnap.data();
const userDocRef = doc(db, "users", postData.author)
const userDocSnap = await getDoc(userDocRef)
return {
id: postDocSnap.id,
avatar: userDocSnap.get("avatar"),
...postData,
};
})
)
}
useEffect(() => {
let cancelPreviousPromiseChain = undefined;
const unsubscribe = onSnapshot(
collection(db, "posts"),
(querySnapshot) => { // using docs here is a bad practice as this is a QuerySnapshot object, not an array of documents
if (cancelPreviousPromiseChain) cancelPreviousPromiseChain(); // cancel previous run if possible
let cancelled = false;
cancelPreviousPromiseChain = () => cancelled = true;
getAvatarsForEachPost(querySnapshot.docs)
.then((postsArray) => {
if (cancelled) return; // cancelled, do nothing.
setLoading(false)
setPosts(postsArray)
})
.catch((error) => {
if (cancelled) return; // cancelled, do nothing.
setLoading(false)
console.log(error)
})
},
(error) => {
if (cancelPreviousChain) cancelPreviousChain(); // now the listener has errored, cancel using any stale data
setLoading(false)
console.log(error)
}
)
return () => {
unsubscribe(); // detaches the listener
if (cancelPreviousChain) cancelPreviousChain(); // prevents any outstanding Promise chains from calling setPosts/setLoading
}
}, [])
Notes:
setLoading(false)
should only be called once you have data, not as soon as you attach the listener as in your original answer.- Consider using
setError()
or similar so you can render a message for your user so they know something went wrong. - You could let a child
<Post>
component handle fetching the avatar URL instead to simplify the above code. - Consider making a
getUserAvatar(uid)
function that has an internal cached map ofuid‑>Promise<AvatarURL>
entries so that many calls for the same avatar don't make lots of requests to the database for the same information.
CodePudding user response:
The issue you are facing is because you are running an await inside a forEach loop. For Each loop doesn't return anything so you cant run await inside it. Please change the forEach to Map your logic should be working fine.