Home > Software design >  I need to pull data from another Firestore collection based on forEach data within a onSnapshot call
I need to pull data from another Firestore collection based on forEach data within a onSnapshot call

Time:12-11

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 of uid‑>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.

  • Related