I want to build a infinite scroll list which push more data when user reach bottom, however the loading UI(loading.jsx) always flash when new data get pushed. But the loading state is already handled by useState.
The process of fetching data works pretty good, it seems like the process of sending data from server to client side trigger the loading UI, but I just want to simply push new data without showing it.
I want to keep loading UI for initially render but not for further data fetching, is there any way to keep the previous data on screen or disable the loading UI?
Currently I'm using nextjs 14 with app router, octokit and tailwind.
here's the demo:
here's my code:
//page.tsx
import React from "react"
import { getIssueListData } from "./testgetIssueListData"
import IssueList from "./TestIssueList"
import IssueItem from "./TestIssueItem"
const ISSUES_PER_LOAD = 30
const page = async () => {
async function getIssueNodeList(pages: number) {
"use server"
const issueListData = await getIssueListData(pages, ISSUES_PER_LOAD)
return issueListData
? issueListData.map((issue) => (
<IssueItem
issueItem={issue}
key={issue.content.id}
/>
))
: null
}
const firstPageData = await getIssueNodeList(1)
return (
<div className="w-full">
<IssueList
firstPageData={firstPageData}
action={getIssueNodeList}
/>
</div>
)
}
export default page
//IssueList.tsx
"use client"
import React, { useEffect, useRef, useState } from "react"
import { useInfiniteScroll } from "./testuseInfiniteScroll"
interface IssueListProps {
firstPageData: React.JSX.Element[] | null
action: (pages: number) => Promise<React.JSX.Element[] | null>
}
const IssueList = ({ firstPageData, action: getIssueNodeList }: IssueListProps) => {
const issueListRef = useRef<HTMLDivElement | null>(null)
const pageRef = useRef(2)
const [list, setList] = useState(firstPageData)
const [isNoMoreData, setIsNoMoreData] = useState(firstPageData === null)
const [isBottom, setIsBottom] = useInfiniteScroll(issueListRef)
useEffect(() => {
if (isBottom === false || isNoMoreData === true) return
async function pushData() {
const newNodeList = await getIssueNodeList(pageRef.current)
if (newNodeList === null) {
setIsNoMoreData(true)
return
}
setList((prev) => [...prev!, ...newNodeList])
pageRef.current += 1
setIsBottom(false)
}
pushData()
}, [isBottom, getIssueNodeList, isNoMoreData, setIsBottom])
return (
<div
className="container flex flex-col bg-primary dark:bg-primary-d max-h-[300px] overflow-auto"
ref={issueListRef}>
{isBottom && !isNoMoreData ? (
<div className="sticky top-[50%] flex justify-center items-center my-4 cursor-default ">Loading...</div>
) : null}
{list}
{isNoMoreData ? <div className="flex justify-center my-4">No More Data!</div> : null}
</div>
)
}
export default IssueList
// IssueItem.tsx
import React from "react"
import Link from "next/link"
const IssueItem = async ({ issueItem }: any) => {
const {
content: { title, number },
} = issueItem
return (
<Link href={`/issue-list/issue/${number}`}>
<div className="font-bold truncate">{title}</div>
</Link>
)
}
export default IssueItem
// getIssueListData.ts
import { Octokit } from "octokit"
export async function getIssueListData(newPage: number, per_page: number = 10) {
const octokit = new Octokit({
auth: null,
})
const res = await octokit.request("GET /repos/{owner}/{repo}/issues", {
owner: "vercel",
repo: "next.js",
per_page: per_page,
page: newPage,
})
const { data } = res
return data.map((issue) => {
const { title, body, id, state, number, user, created_at, updated_at } = issue
return {
content: {
title,
body,
id,
state,
number,
created_at,
updated_at,
},
user: user,
}
})
}
// useInfiniteScroll.ts
import { MutableRefObject, useEffect, useState } from "react"
const TEST_THROTTLE_DELAY = 200
function throttle(callbackFn: () => void, delay = 500) {
let timer: ReturnType<typeof setTimeout> | null = null
return () => {
if (timer !== null) return
timer = setTimeout(() => {
callbackFn()
timer = null
}, delay)
}
}
export function useInfiniteScroll(scrollRef: MutableRefObject<HTMLDivElement | null>, triggerHeight: number = 10) {
const [isBottom, setIsBottom] = useState(false)
useEffect(() => {
if (scrollRef.current === null) throw Error("No scroll ref in useInfiniteScroll")
const scrollDiv = scrollRef.current
function scrollHandler() {
const { scrollHeight, scrollTop, clientHeight } = scrollDiv
const isReachBottom = clientHeight + scrollTop > scrollHeight - triggerHeight
if (isReachBottom) setIsBottom(true)
}
scrollDiv.addEventListener("scroll", throttle(scrollHandler, TEST_THROTTLE_DELAY))
return () => {
scrollDiv.removeEventListener("scroll", throttle(scrollHandler, TEST_THROTTLE_DELAY))
}
})
return [isBottom, setIsBottom] as [boolean, (state: boolean | (() => boolean)) => void]
}
// loading.tsx
import React from "react"
const Loading = () => {
return <div>loading</div>
}
export default Loading
I tried using useRef to keep data but not working
After adding "use client" in IssueItem.tsx then remove the async syntax, everything work as expected.
It seems due to IssueItem is rendered in server action so it remain being server component, server sends the loading UI before component finish rendering. When IssueItem is specified as client component, no loading UI needed to be presented because it is now rendered on client.
Pretty simple solution, but it still takes me hours to figure it out. If there anything misleading, please let me know.