Nextjs 14, Loading UI flickering when push more data in infinite scroll list from server action

19 Views Asked by At

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:

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

1

There are 1 best solutions below

0
Kahon On

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.