Next.jsでスケルトンを使用した無限スクロールを実装する！

# はじめに
こんにちは、H×Hのセンリツ大好きエンジニアです。（同担OKです😉）
Next.jsでSPAにするんだったら、一覧画面での無限スクロールは実装したいですよね！
今回は、そんな方へ向けて無限スクロールを実装していきます！

# 無限スクロール実装
今回の無限スクロールには、`Intersection Observer API`、`useState`と`useEffect`、`useRef`と`useCallback`を使用していきます。
それぞれの説明は以下の通り
- `Intersection Observer API`：特定の要素がビューポート（表示領域）や他の特定の要素と交差するかどうかを非同期的に観察するためのAPI
- `useState`：コンポーネントの状態を管理するためのフック
- `useEffect`：副作用（データフェッチ、DOM操作など）を処理するためのフック
- `useRef`：DOM要素やインスタンス変数へのアクセスを可能にするフック
- `useCallback`：メモ化されたコールバック関数を返すためのフック。関数の再生成を防ぎ、パフォーマンスを最適化する


手順としては、ざっくりと
1. offset付きのAPIから初めの数件データを取得しuseStateに管理
1. Intersection Observer APIを使用して、指定されたDOM要素がビューポートに入った時にoffset増加
1. offsetが更新されるたびにAPIから追加で数件データを取得し、useStateに追加
1. 指定されたDOMの監視はuseRefを使用し、DOM要素がビューポートに入る度にオブザーバーを解除することで無限スクロールが可能となる

。。。説明聞いてるだけだと分かりにくいですよね😇
実際にコードを見ていただいた方が早いので、ご覧いただきましょう！
以下のコードは、記事一覧を無限スクロールしたものになっています！

``` InfiniteScroll.tsx
"use client"

import { ArticleProps } from "@/components/molecules/ArticleCard/ArticleCard"
import { CONST, STATUS_CODE } from "@/const"
import { useCallback, useEffect, useRef, useState } from "react"
import { Loading } from '@/components/molecules/Loading'
import { NoArticle } from '@/components/atoms/NoArticle'
import { ArticleList } from '@/components/organisms/ArticleList'
import { Box, Container, Text } from '@chakra-ui/react'

export const InfiniteScroll = () => {
  const [ offset, setOffset ] = useState(1)
  const [ articles, setArticles ] = useState<ArticleProps[]>([])
  const loader = useRef<HTMLDivElement | null>(null)
  const [ isVisible, setIsVisible ] = useState(true)

  const handleObserver = useCallback((entities: IntersectionObserverEntry[]) => {
    const target = entities[0]
    if (target.isIntersecting) {
      setOffset((prev) => prev + 1)
    }
  }, [])

  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api${CONST.ARTICLES}?per_page=${offset}`)
      if (response.ok) {
        const result = await response.json()
        switch (result.status) {
          case STATUS_CODE.OK:
            if (result.data.length === 0) {
              setIsVisible(false)
            }
            setArticles((prev) => [...prev, ...result.data])
            break // 成功時の処理が完了したらbreakを忘れずに
          default:
            alert(result.status)
            break
        }
      } else {
        alert(response.statusText);
      }
    }
    fetchData()
  }, [offset])

  useEffect(() => {
    const observer = new IntersectionObserver(handleObserver, {
      root: null,
      rootMargin: "20px",
      threshold: 0.5
    });
    if (loader.current) observer.observe(loader.current);

    return () => {
      observer.disconnect();
    };
  }, [handleObserver]);

  return (
    <>
      <Text fontSize="32px" fontWeight={600} lineHeight={1.8} mt="30px" textAlign="center">
        新着記事一覧
      </Text>
      <Container maxW="container.md" py="5%">
        <ArticleList articles={articles} />
        {!isVisible && articles.length === 0 && <NoArticle />}
        <Box ref={loader} h="1px" mt="19px" />
        {isVisible && <Loading />}
      </Container>
    </>
  )
}
```

うひゃあ〜！
コードがいっぱいですね！🫣
噛み砕きながら解説していきます。

### 定数の初期化
``` InfiniteScroll.tsx
const [ offset, setOffset ] = useState(1)
const [ articles, setArticles ] = useState<ArticleProps[]>([])
const loader = useRef<HTMLDivElement | null>(null)
const [ isVisible, setIsVisible ] = useState(true)
```

これはそれぞれ以下のようになっています！
- `offset`: 現在のページ番号を管理
- `articles`: 取得した記事の配列を管理
- `loader`: DOM要素の参照を保持
- `isVisible`: 無限スクロールが続くか示す

### DOM要素の表示判定と`offset`の増加
``` InfiniteScroll.tsx
const handleObserver = useCallback((entities: IntersectionObserverEntry[]) => {
  const target = entities[0]
  if (target.isIntersecting) {
    setOffset((prev) => prev + 1)
  }
}, [])
```

`handleObserver`は、監視対象のDOM要素がビューポート内に表示された場合に`offset`を増加させて、次のデータをフェッチするためのトリガーになります。
`if (target.isIntersecting)`の箇所でビューポート内にDOM要素が表示されたか判定します。

また、`useCallback`を使用することで無駄な再生成を防いでいます。

### `offset`が更新される度にAPIから記事データを取得
``` InfiniteScroll.tsx
useEffect(() => {
  const fetchData = async () => {
    const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api${CONST.ARTICLES}?per_page=${offset}`)
    if (response.ok) {
      const result = await response.json()
      switch (result.status) {
        case STATUS_CODE.OK:
          if (result.data.length === 0) {
            setIsVisible(false)
          }
          setArticles((prev) => [...prev, ...result.data])
          break // 成功時の処理が完了したらbreakを忘れずに
        default:
          alert(result.status)
          break
      }
    } else {
      alert(response.statusText);
    }
  }
  fetchData()
}, [offset])
```

`offset`が変更されるたびに記事データを取得する非同期関数を定義し、APIからデータをフェッチして記事の配列を更新します。

また、もし取得したデータが空の場合、isVisibleをfalseに設定します。

### DOM要素の監視とクリーンアップ
``` InfiniteScroll.tsx
useEffect(() => {
  const observer = new IntersectionObserver(handleObserver, {
    root: null,
    rootMargin: "20px",
    threshold: 0.5
  });
  if (loader.current) observer.observe(loader.current);

  return () => {
    observer.disconnect();
  };
}, [handleObserver]);
```

この`useEffect`フックは、`Intersection Observer`を設定し、監視対象のDOM要素がビューポートに表示されるタイミングを検出するための設定を行っています。

`IntersectionObserver`コンストラクタを使って新しいオブザーバーを作成する際に、以下のものを設定します。
- `handleObserver`：オブザーバーが検出した交差状態の変化を処理するためのコールバック関数
- `root`：観察対象の要素が交差するルート要素を指定します。nullはビューポートを表す
- `rootMargin`：ルート要素の周囲のマージンを指定
- `threshold`：コールバックが呼び出される対象要素の可視部分の割合を指定します。0.5は50%を意味し、要素が50%以上表示されるとコールバックが呼び出される

`if (loader.current) observer.observe(loader.current);`では、`useRef`で作成されたDOM要素が存在する場合、監視を開始します。
そしてこのDOM要素がビューポート内に表示されるたび`handleObserver`が呼び出される仕組みとなっています。

`handleObserver`が呼び出され、DOM要素がビューポートを離れるとオブザーバーが解除されます。

これにより、無限スクロールが実現するんですねえ😎

### 描画部分
``` InfiniteScroll.tsx
return (
  <>
    <Text fontSize="32px" fontWeight={600} lineHeight={1.8} mt="30px" textAlign="center">
      新着記事一覧
    </Text>
    <Container maxW="container.md" py="5%">
      <ArticleList articles={articles} />
      {!isVisible && articles.length === 0 && <NoArticle />}
      <Box ref={loader} h="1px" mt="19px" />
      {isVisible && <Loading />}
    </Container>
  </>
)
```

`{!isVisible && articles.length === 0 && <NoArticle />}`は、無限スクロールが続かない＆記事データが0件だった場合に表示されるものです。
中身としては「記事が見つかりませんでした。」という風に出る想定です。

`<Box ref={loader} h="1px" mt="19px" />`は、`Intersection Observer`によって監視される要素です。
これが画面内に入ると記事データが続々と取得されます。

`{isVisible && <Loading />}`は、無限スクロールが続く場合画面下部にローディングを流し続けるものになります。
これにより、記事データ取得中にローディングが流れるように見えます。
（こういう実装が良いかはさておき😰）

ちなみに、`<Loading />`はChakraUIのSkeltonを元に作成した、記事データのスケルトンが入っています！
これは簡単にSkeltonが作れるのでオススメです！（ChakraUIを使っている方にとっては）

https://v2.chakra-ui.com/docs/components/skeleton

### 実践
![inifinite_scroll.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2653241/69a868f1-45cd-8f2e-3ee2-2e09a4e55ade.gif)

ちょっとガクガクですが、きちんと無限スクロールが実装できていることが確認できますね！
スケルトンを入れることで、下の方に行くと読み込み中だと分かりやすくしています！

実装できて喜びの笑み🤗

# おわりに
いかがでしたでしょうか！
今回は、Next.js（React）での無限スクロールを紹介しました！

こういう風に、UIを現代風にすることでオシャレ感が増してカッコいいですね！🥹

ご参考になれば幸いです😍
以上、センリツでした🤓