[Next.js]: Implementing infinite scroll in Next.js

Next.js

05/12/2020


Overview

This post will explain how to build infinite scrolling with server side rendering in Next.js.

Install packages

Unless you're using Next.js @^9.4, you need to install node-fetch

BASH
npm i node-fetch

getServerSideProps()

We define getServerSideProps() that is not much different from a regular fetch function, but the difference is that the server generates the page HTML upon each request, and it can exist only inside a page component.

Since we're building an infinite scroll, we're not going to fetch the whole data at once, but instead, we'll request a partial data from the server by using query parameter that contains which page to navigate to. It will later be passed from UserList component.

/pages/index.js
JSX
import fetch from "node-fetch"
import UserList from "../components/UserList"
const HomePage = ({ userData }) => {
return (
<div className="home-page">
<UserList userData={userData} />
</div>
)
}
export const getServerSideProps = async ({ query }) => {
// Fetch the first page as default
const page = query.page || 1
let userData = null
// Fetch data from external API
try {
const res = await fetch(`${process.env.FETCH_URL}/users?page=${page}`)
if (res.status !== 200) {
throw new Error("Failed to fetch")
}
userData = await res.json()
} catch (err) {
userData = { error: { message: err.message } }
}
// Pass data to the page via props
return { props: { userData } }
}
export default HomePage

Backend API

Let's take a look at server side API for retrieving user. It returns 30 users per load (perPage) along with current page (curPage) and maximum Page (maxPage).

JS
const getUsers = async (req, res, next) => {
const curPage = req.query.page || 1
// Display 30 users per page load
const perPage = 30
try {
// Fetch users based on current page * perPage
const users = await User.find().limit(perPage * curPage)
const totalUsers = await User.find().countDocuments()
res.status(200).json({
message: "Fetched users",
users: users,
curPage: curPage,
maxPage: Math.ceil(totalUsers / perPage),
})
} catch (err) { // ... }
}

UserList component

UserList renders list of users, and scrolling to the last user triggers fetch for the new page with handleScroll

/components/UserList/index.js
JSX
import React, { useState, useEffect } from "react"
import { useRouter } from "next/router"
import "./styles.scss"
const UserList = ({ userData }) => {
const [users, setUsers] = useState([])
const router = useRouter()
// Set users from userData
useEffect(() => {
if (userData) {
if (userData.error) {
// Handle error
} else {
setUsers(userData.users)
}
}
}, [userData])
// Listen to scroll positions for loading more data on scroll
useEffect(() => {
window.addEventListener("scroll", handleScroll)
return () => {
window.removeEventListener("scroll", handleScroll)
}
})
const handleScroll = () => {
// To get page offset of last user
const lastUserLoaded = document.querySelector(
".user-list > .user:last-child"
)
if (lastUserLoaded) {
const lastUserLoadedOffset =
lastUserLoaded.offsetTop + lastUserLoaded.clientHeight
const pageOffset = window.pageYOffset + window.innerHeight
// Detects when user scrolls down till the last user
if (pageOffset > lastUserLoadedOffset) {
// Stops loading
if (userData.curPage < userData.maxPage) {
// Trigger fetch
const query = router.query
query.page = parseInt(userData.curPage) + 1
router.push({
pathname: router.pathname,
query: query,
})
}
}
}
}
return (
<>
<ul className="user-list">
{users.length > 0 &&
users.map((user, i) => {
return (
<li className="user" key={i}>
<span>{user.name}</span>
</li>
)
})}
</ul>
</>
)
}
export default UserList

Adding loader between page navigation

Currently, scrolling down to the last user may trigger fetch multiple times!. You'll need to add a Router event to detect whether the fetch has ended to prevent multiple fetches.

/components/UserList/index.js
JSX
import React, { useState, useEffect } from "react"
import Router, { useRouter } from "next/router"
import "./styles.scss"
const UserList = ({ userData }) => {
const [users, setUsers] = useState([])
const router = useRouter()
const [loading, setLoading] = useState(false)
const startLoading = () => setLoading(true)
const stopLoading = () => setLoading(false)
// Set up user data
useEffect(() => {
if (userData) {
// Error check
if (userData.error) {
// Handle error
} else {
setUsers(userData.users)
}
}
}, [userData])
// Router event handler
useEffect(() => {
Router.events.on("routeChangeStart", startLoading)
Router.events.on("routeChangeComplete", stopLoading)
return () => {
Router.events.off("routeChangeStart", startLoading)
Router.events.off("routeChangeComplete", stopLoading)
}
}, [])
// Listen to scroll positions for loading more data on scroll
useEffect(() => {
window.addEventListener("scroll", handleScroll)
return () => {
window.removeEventListener("scroll", handleScroll)
}
})
const handleScroll = () => {
// To get page offset of last user
const lastUserLoaded = document.querySelector(
".user-list > .user:last-child"
)
if (lastUserLoaded) {
const lastUserLoadedOffset =
lastUserLoaded.offsetTop + lastUserLoaded.clientHeight
const pageOffset = window.pageYOffset + window.innerHeight
if (pageOffset > lastUserLoadedOffset) {
// Stops loading
/* IMPORTANT: Add !loading */
if (userData.curPage < userData.maxPage && !loading) {
// Trigger fetch
const query = router.query
query.page = parseInt(userData.curPage) + 1
router.push({
pathname: router.pathname,
query: query,
})
}
}
}
}
return (
<>
<ul className="user-list">
{users.length > 0 &&
users.map((user, i) => {
return (
<li className="user" key={i}>
<span>{user.name}</span>
</li>
)
})}
</ul>
{loading && <h1>Loading ...</h1>}
</>
)
}
export default UserList

Extra: Styling for user

/components/UserList/styles.scss
SCSS
.user {
padding: 1rem;
}
.user-list {
list-style: none;
display: flex;
justify-content: center;
flex-wrap: wrap;
.user {
padding: 2rem;
margin: 1rem;
background: #ccc;
}
}

WRITTEN BY

Keeping a record