Infinite scrolling using field policy InMemoryCache

Hello everyone :wave:

While developing my app I got to the point where I need to implement infinite scrolling on one of my pages. Apollo and Prisma both have good documentation, but nothing on wiring it up and with a gap in rw docs on implementing it I decided I’d post how I ended up getting it working. Hopefully this helps others that may be as unfamiliar as I was.

First, this is how I implemented infinite scrolling…I’m sure there is logic or a completely different approach that works just as well or better, but this is meant to show how everything should connect up.

Method
Infinite scrolling with a field policy to cache and merge existing with incoming data. Using networkStatus to add loading indicator while fetching more data. Not showing how to present the data on the front-end, that will be different for every use-case (using React Waypoint or another library is a good start though).

Context
My infinite scroll page is for media. I’m getting all media from a specific user (profile) and returning the results… over and over until there is no more.

SDL
I added a pageInfo type in my media sdl to provide nextCursor, nextPage, and profileId from the api side.

The query I’m using here is mediasByProfileId

 type Media {
    id: Int!
    platformId: String!
    type: String
    fullUrl: String
    thumbnailUrl: String
    createdOn: Int
    Post: Post!
    postId: Int!
  }

  type PageInfo {
    hasNextPage: Boolean
    nextCursor: Int
    profileId: Int
  }

  type MediaGallery {
    medias: [Media]
    pageInfo: PageInfo
  }

  type Query {
    medias: [Media!]! @requireAuth
    media(id: Int!): Media @requireAuth
    mediasByProfileId(profileId: Int!, cursor: Int): MediaGallery! @skipAuth
  }

Service
For the service, I take two arguments - profileId and cursor. If no cursor is passed, means I don’t need to add that property on the request.

There certainly is other ways to see if there are more results, but this is the simplest. I also grab the cursor and profileId to send back in the return request.

Prisma - Cursor pagination docs

export const mediasByProfileId = async ({ profileId, cursor }) => {
  const fetchAmount = 10

  let mediasResponse
  if (cursor) {
    mediasResponse = await db.media.findMany({
      take: fetchAmount,
      skip: 1,

      cursor: {
        id: cursor,
      },
      orderBy: {
        id: 'desc',
      },
      where: {
        canView: true,
        Post: {
          profileId: profileId,
        },
      },
      select: {
        id: true,
        type: true,
      },
    })
  } else {
    mediasResponse = await db.media.findMany({
      take: fetchAmount,
      orderBy: {
        id: 'desc',
      },
      where: {
        canView: true,
        Post: {
          profileId: profileId,
        },
      },
      select: {
        id: true,
        type: true,
      },
    })
  }

  const hasNextPage = mediasResponse.length === fetchAmount ? true : false
  const nextCursor =
    mediasResponse.length === fetchAmount
      ? mediasResponse[mediasResponse.length - 1].id
      : null
  const MediaGallery = {
    medias: mediasResponse,
    pageInfo: {
      hasNextPage: hasNextPage,
      nextCursor: nextCursor,
      profileId: parseInt(profileId),
    },
  }
  return MediaGallery
}

Cache - Field policy

Apollo Core pagination API and Cursor-based Docs

Created the file in web > src. Here you can specify the name of your request (mediasByProfileId for me), keyArgs, and any merge logic that suits your application.

Depending on use case you can either leave keyArgs set to false or whatever args you want. If you’re just querying something with no where clause, like all comments, then set to false. If like me you need to get all media by a profile, then set to a unique arg thats passed so it’s not all merged together.

Logic here is very straight forward – if a query is made with a cursor, merge existing with incoming. If no cursor but have existing data just give me that (because it’ll make the initial query and duplicate data). And lastly, if no cursor and no existing data just give me the incoming data. Then of course update the new pageInfo data from incoming.

import { InMemoryCache } from '@apollo/client'

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        mediasByProfileId: {
          keyArgs: ['profileId'],
          merge(existing = { medias: [] }, incoming, { args: { cursor } }) {
            const existingData = existing.medias.length > 0 ? true : false
            return {
              medias: cursor
                ? [...existing.medias, ...incoming.medias]
                : existingData
                ? [...existing.medias]
                : [...incoming.medias],
              pageInfo: incoming.pageInfo,
            }
          },
        },
      },
    },
  },
})

export default cache

App.js

You’ll need to add that field policy file to RedwoodApolloProvider in the app.js

import { AuthProvider } from '@redwoodjs/auth'
import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web'
import { RedwoodApolloProvider } from '@redwoodjs/web/apollo'

import FatalErrorPage from 'src/pages/FatalErrorPage'
import Routes from 'src/Routes'
import cache from './cache'

import './scaffold.css'
import './index.css'
const App = () => (
  <FatalErrorBoundary page={FatalErrorPage}>
    <RedwoodProvider titleTemplate="%PageTitle | %AppTitle">
      <AuthProvider type="dbAuth">
        <RedwoodApolloProvider graphQLClientConfig={{ cache }}>
          <Routes />
        </RedwoodApolloProvider>
      </AuthProvider>
    </RedwoodProvider>
  </FatalErrorBoundary>
)

export default App

Cell and graphql query

Crude example, just dumping data on the page with a button that calls the fetchMedia function which calls apollo’s fetchMore. When hasNextPage returns false, button gets disabled (or fetchMedia would stop getting called in a real infinite scroll example).

Ideally you’d implement which ever infinite scrolling behavior best suits your app. I’ll probably use React Waypoint.

You’ll need to add fetchMore and networkStatus in the Success function params.

I created a simple function fetchMedia which is called from a button’s onClick event. Whichever library or implementation you use would call this when user scrolls to end of list (or offset from end).

networkStatus returns a 3 when fetchMore request is still in flight, so showing simple example how to add a loading indicator while page is fetching more data.

export const QUERY = gql`
  query FindMediaGalleryQuery($id: Int!, $cursor: Int) {
    mediasByProfileId(profileId: $id, cursor: $cursor) {
      medias {
        id
        type
      }
      pageInfo {
        hasNextPage
        nextCursor
        profileId
      }
    }
  }
`

export const Loading = () => <div>Loading...</div>

export const Empty = () => <div>Empty</div>

export const Failure = ({ error }) => (
  <div style={{ color: 'red' }}>Error: {error.message}</div>
)

export const Success = ({ mediasByProfileId, fetchMore, networkStatus }) => {
  const fetchMedia = () => {
    fetchMore({
      variables: {
        id: mediasByProfileId.pageInfo.profileId,
        cursor: mediasByProfileId.pageInfo.nextCursor,
      },
    })
  }
  return (
    <div>
      {JSON.stringify(mediasByProfileId)}
      {mediasByProfileId.pageInfo.nextCursor}
      <div>
        <button
          type="button"
          className="px-6 py-1 text-lg text-white bg-blue-600 rounded focus:outline-none disabled:opacity-25"
          onClick={fetchMedia}
          disabled={!mediasByProfileId.pageInfo.hasNextPage}
        >
          More
        </button>

        {networkStatus === 3 && (
          <svg
            role="status"
            class="w-8 h-8 mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600"
            viewBox="0 0 100 101"
            fill="none"
            xmlns="http://www.w3.org/2000/svg"
          >
            <path
              d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
              fill="currentColor"
            />
            <path
              d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
              fill="currentFill"
            />
          </svg>
        )}
      </div>
    </div>
  )
}

Hope this helps others. Always glad to hear feedback, or other solutions.

4 Likes

I didn’t like having to go through Apollo innards to do simple infinite scroll and implemented it differently.

SDL

  type Query {
   ...
    myPaginatedQuery(
      cursorId: Int
      take: Int
      skip: Int
    ): [Thing!]! @skipAuth
  }

Resolver

export const queryThingsPaginated = async ({
  cursorId,
  take,
  skip = 0,
}) => {
  const query = { take, skip, orderBy: { id: 'desc' } }

  if (cursorId) {
    query.cursor = { id: cursorId }
  }

  return db.thing.findMany(query)
}

Cell Usage

<ThingCellPaginated take={10} />

Thing List Component

const QUERY = gql`
  query ($cursorId: Int, $take: Int, $skip: Int) {
    newThings: queryThingsPaginated(cursorId: $cursorId, take: $take, skip: $skip) {
      // Thing type properties
    }
  }
`

const ThingList = ({ initialThingsFromCell }) => {
  const [things, setThings] = useState(initialThingsFromCell)
  const client = useApolloClient()

  const handleLoadMoreThings = async () => {
    const oldThings = [...things]

    const response = await client.query({
      query: QUERY,
      variables: {
        skip: 1,
        take: 5,
        cursorId: oldThings[oldThings.length - 1].id,
      },
    })

    setThings([...oldThings, ...response.data.newThings])
  }

  return (
    <div>
      ...
      <button onClick={handleLoadMoreThings}>Load More Things</button>
    </div>
  )
}

Basically, the Cell is responsible for initial queries. It will fetch an initial amount of Things (in this case 10) and then leave the imperative pagination fetching in the component itself. Within List component, I’m just using a handler to fetch what I need with the exact same query. In the actual implementation, it’s on scroll, but I wanted to simplify the example with a button loader.

I’m really happy with it since:

  • I didn’t need to mess with the Apollo client settings
  • I can keep using the same patterns in I’m used to
  • The paginated query turned out to be really portable and composable
  • I’m using response cache so the paginated queries should get cached as well

I just checked to see if duplicate responses would be appended to the list (if i spam the handler), but it’s working fine.

3 Likes

Thanks! Like this simple approach. I implemented it myself. The issue I encountered was that since in your example in the Resolver you ordered the query by id descending. With this the wrong id would be taken for the next things. So the fix for me just was to change it to const query = { take, skip, orderBy: { id: 'asc' } } to have the correct cursorId.

For those who are interested, I implemented it in my version of the redwoodjs tutorial. See here: Infinite scroll by ronatory · Pull Request #7 · ronatory/redwood-tutorial · GitHub

My solution isn’t wrong.

You can sort by cursor asc/desc as long as the column sequential and unique.

### Use cases for cursor-based pagination

Infinite scroll - for example, sort blog posts by date/time descending and request 10 blog posts at a time.

Paging through an entire result set in batches - for example, as part of a long-running data export.


### Sorting and cursor-based pagination

Cursor-based pagination requires you to sort by a sequential, unique column such as an ID or a timestamp. This value - known as a cursor - bookmarks your place in the result set and allows you to request the next set.

You’re probably just expecting asc sorting in your fetch. However, in most cases of pagination, you will probably want the most recent item first– which is usually the one with the highest ID in a SQL db. You can also sort by timestamp like Prisma says, but you need to make sure the column is unique or the resulting set will have omitted items.

Also, welcome to the RedwoodJS community!

1 Like

Glad you found use from the thread. You seemed to use Rotocite’s approach, which is a great one for a simple straight forward approach.

I didn’t look too much into theirs when it was initially posted, but I do see the benefit along with some limitations.

I didn’t consider what you mentioned, not sure why you’d need to sort asc in that case. In my case sorting desc is working (and important for my frontend).

I think rodocite already highlighted the benefits of that approach, so I’ll just mention some things that stood out to me. Managing the data, especially if you have more than one entry. For example, if you query posts by user, you’d have multiple entries. Not tested, but currently if your query got posts by user, it would cache all posts into the array/object, whereas Apollo Client cache automatically stores these two objects separately.

Then there is garbage collection and “evict” using Apollo, which allows you to selectively remove cached data that is no longer useful. Plus all the other possibly useful tidbit’s if it’s ever needed like customizing field responses, bypassing, resetting, redirecting, or persisting the cache.

Either approach is great if it fits your needs, I went this direction since I knew my app would need more flexibility and features that Apollo provides without me having to write the custom logic myself.

1 Like

Thanks for welcoming me to the RedwoodJS community and thanks for the reply and clarification!

Ahh that might be that I was confused. Yes, I was indeed expecting asc sorting in my fetch in the tutorial. In my real use case of another app I ended up using Offset pagination because I already needed to order the query by some other field which is mandatory for me. So without this sorting i couldn’t achieve my use case goal. Or is there a way to use cursor-based pagination when another order by is required? Maybe I was just to quick without further research, but right now I have a satisfying solution :smiley:

Correct me if I’m wrong somehow.

Thanks also for your reply! Right now I just needed something quick for a simple MVP, so that’s why I just used the simple approach from @rodocite. Thanks for mentioning further details about your approach. I will definitely have this thread in mind if an app of mine needs something of these benefits. Anyway these details are probably also good for others which need to decide what they need, so good that you mentioned them. Thanks a lot!

@ronatory The Prisma docs highlight the problems with Offset pagination. Mainly, it doesnt scale.

What you may want to consider is client-side sorting on top of the query if you want to sort by another field. The efficiency of that varies wildly though and dependent on UX and your data modeling.

Yeah I agree with @rodocite, offset pagination doesn’t scale well. As far as sorting with cursor based pagination, you can sort by any valid cursor. Needs to be sequential and unique, which typically is id, but can be a timestamp or something similar.

@rodocite @Cromzinc Thanks a lot for your replies again! They motivated me to try implementing cursor based pagination in the app I’m building even the sorting needed to be done with a not unique column at first.

A little bit more context regarding my issue is that the column by which the order needed to be done has a price value. In my case integer values e.g. “123”, “456”, “32” or “100000”. So there will exist easily duplicated values. Here is how I made it work.

I changed the field in the db to string type and when submitting an integer value via the frontend e.g. “123”, I just added leading zeros so that the number has always 9 digits e.g. “000000123” and converted it to a string, then to make the value unique I generated a 6 digit uuid and added it at the end with a dash e.g. “000000123-lkpajo”. With this the ordering works as expected and I only need to convert the value in the frontend with helper functions to use only the part before the dash, remove the leading zeros and convert it into an int. So far it works. Maybe it could be better done, but I’m satisfied for now.

Maybe helpful for others also.

Some helpful sources also: