Infinite scrolling with GraphQL and Apollo
Tags: dohackathon, webdev, graphql, programming
Published: 2021-01-05 17:44:20

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](https://www.apollographql.com/docs/react/pagination/core-api/#the-fetchmore-function) 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. 

```javascript
  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);
    },
  });
``` 

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

```javascript
<div onScroll={onScroll}>...</div>
```

and here's the code for the implementation

```javascript
  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);
  };
```

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.

```javascript
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 };
      }
    }
  }
}
```

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! 🚀