DEV Community

Jiayi
Jiayi

Posted on

Infinite scrolling with GraphQL and Apollo

By participating in this hackathon, I've technically learned how to build API in a totally different way than what I've used to which is REST API. I'm discovering Apollo methods such as fetchMore which I think it's super cool!

So, one of the features I had in mind for the Goodeed app is infinite scrolling. Eventhough I don't have a lot of dumb data, I'd still love for this to be a new-thing-I've-learned.

In the midst of investigating on how to implement this, I found Apollo's fetchMore which suits my requirement perfectly, but not quite...

To jump start this project as quickly and as "cheap" as I can, I've decided to use MongoDB Atlas as the database so I don't have to worry much about DB and concentrate on the rest. One of the requirements for the app is to always sort the posts by the location of the user first (closest to farthest), then by recent date.

Since I've chosen to use the free tier cluster, there are some MongoDB methods that are not permitted such as aggregate or mapReduce which is what I would use IDEALLY. But oh well, I guess I'd have to use Javascript methods which is quicker and easier but not as performant and it looks like a dirty workaround 😅.

Okay so let's get started. First, I have a gql query of GET_POSTS and I'm using Apollo's useQuery to get posts when the page is mounted.

  const GET_POSTS = gql`
    query GetPosts($offset: Int) {
      posts(offset: $offset) {
        content {
          ...
        }
        hasMore
      }
    }
  `;

    const { loading, error, fetchMore } = useQuery(GET_POSTS, {
    onCompleted: data => {
      const { content, hasMore } = data.posts;
      setPosts(content);
      setHasMorePosts(hasMore);
    },
  });
Enter fullscreen mode Exit fullscreen mode

Notice the fetchMore in the useQuery destructured properties? This is a function/method given by Apollo to basically re-use the GraphQL query but you're free to pass the variables.

What I want is that when the user scrolls down and reaches the bottom of the page, I want to fetch more posts. So here's where the offset comes in. I have an onScroll event that I've attached to the div containing the posts like so

<div onScroll={onScroll}>...</div>
Enter fullscreen mode Exit fullscreen mode

and here's the code for the implementation

  const onScroll = e => {
    // if div is at the bottom, fetch more posts
    if (e.target.scrollTop + e.target.clientHeight === e.target.scrollHeight) {
      // if there are no more posts to fetch, don't do anything
      if (!hasMorePosts) return;

      setTimeout(() => {
        setIsFetchMoreLoading(true);
        fetchMorePosts();
      }, 300);

      return;
    }
  };

  const fetchMorePosts = async () => {
    setOffset(prev => prev + 1);

    const fetchedMore = await fetchMore({
      variables: { offset: offset + 1 },
    });

    const { content, hasMore } = fetchedMore.data.posts;
    setPosts(previousPosts => [...previousPosts, ...content]);
    setHasMorePosts(hasMore);
    setIsFetchMoreLoading(false);
  };
Enter fullscreen mode Exit fullscreen mode

Alright, it seems pretty straightforward. The onScroll event will listen for scroll events and if the user is at the bottom of the page, I want to fetch more posts. However, if there's no more posts to fetch, I just want to return and do nothing.

In the fetchMorePosts function, I'm calling Apollo's fetchMore with new offset variable. I'll show what's happening in the resolver now.

const resolvers = {
  Query: {
    posts: async (_parent, { offset }, { db, loggedUser }, _info) => {
      if (!loggedUser) throw new AuthenticationError('you must be logged in');

      const { location } = await db.collection('users').findOne({ username: loggedUser.username });

      let posts;

      const allPosts = await db
        .collection('posts')
        .find()
        .toArray();

      const sortedByDate = allPosts.reverse();

      // mongodb mapReduce not supported for free tier cluster :(
      // Workaround -> handle sort using JS methods

      if (location) {
        const compareDistance = (postLat, postLng) => calcDistance(postLat, postLng, location.lat, location.lng);
        // sort by distance (closest -> farthest)
        posts = sortedByDate.sort(
          (a, b) => compareDistance(a.location.lat, a.location.lng) - compareDistance(b.location.lat, b.location.lng)
        );
      } else {
        posts = sortedByDate;
      }

      // sort by offset

      if (offset) {
        // if offset is not null (not the initial fetch)
        const newOffsetValue = offset * FETCH_LIMIT;
        const payload = posts.slice(newOffsetValue, FETCH_LIMIT + newOffsetValue);

        return { content: payload, hasMore: !!payload.length };
      } else {
        const payload = posts.slice(0, FETCH_LIMIT);

        return { content: payload, hasMore: !!payload.length };
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The offset is from where do I want to start and limit is how many do I want. So, on the initial fetch where offset is undefined (which falls in the if(offset) else block), I want to give them the posts from 0 - LIMIT (let's say 5). And the next query from fetchMore will give us the offset of 1, and now we will slice the array by (5,10).

I'm also returning hasMore in the response as I wanted to handle the fetch more loading state on the frontend side.

I've learned a lot and will be doing so even more. It's also been a while since I've coded in React so... fair game. There's definitely a more efficient way of sorting the DB on the server and handling UI on the client (I chose to use react state instead of Apollo's inMemoryCache). Perhaps with more time and resources, these things can be improved :)

Until then, happy hacking! 🚀

Top comments (0)