Hello everyone
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.