【無限スクロール】React（next.js）で使えるカスタムフック（micro CMS利用）

## 作成するもの
画面下部までスクロールした際、5件取得し表示する機能
![Videotogif (3).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3486141/bcfd934a-52db-a55c-920a-b9c83730e325.gif)


## 本記事の前提
「オフセットとリミット機能を備えたAPIからデータが取得できること」

・オフセット→取得を開始するデータのインデックス
・リミット→データを取得する数

例えば、データを5件初期表示しておき、スクロール量が一定超えた時また5件所得する際を考えます。
オフセットが0→5と更新されたとき、そのインデックス（5）からリミットの数（5）だけ進んだインデックス（9）までの数だけ取得することができるAPI、という具合です。

本記事では自作でAPIを作ると本題に注力できないため、オフセットとリミットにデフォルトで対応しているmicro CMSのAPIを使います。micro CMSのアカウントを作成し、記事を20記事程度入稿しておくと無限スクロールをよく体感できるのでその想定で進めます。（あとtailwind css使ってます）

## データ取得の関数定義
```page.tsx
//Next.js13の場合記載
"use client";
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
import { useEffect, useState } from "react";

//microcmsで定義したスキーマにより異なります。
type Post = {
	createdAt: string;
	id: string;
	publishedAt: string;
	revisedAt: string;
	title: string;
	updatedAt: string;
};

export default function Home() {
	const [posts, setPosts] = useState<Post[]>([]);
	//offsetの初期値は0
	const [offset, setOffset] = useState(0);
	const [hasMore, setHasMore] = useState(true);

	const fetchPosts = async (offset: number) => {
		const res = await fetch(
			//limitは取得数のため不変。今回は5個ずつ取得するため5を指定（?limit=5）offsetはユーザーのスクロールごとに更新するため変数に
			`https://z38yw2vaet.microcms.io/api/v1/scroll/?limit=5&offset=${offset}`,
			{
				headers: {
					//キーを設定します。自身以外に漏れてしまうとまずいので本来フロントでapiコールすべきでないのですが本題とずれるため割愛します。
					"X-MICROCMS-API-KEY": xxxxxxxx,
				},
			}
		);
		if (res.ok) {
			const data = await res.json();
			console.log(data.contents);
			//micro cmsがcontentsというオブジェクトにデータを保持しているためdata.contentsとする
			return data.contents;
		} else {
			alert("失敗しました");
		}
	};
    //スクロールの条件が一定を超えた時（条件は次のカスタムフックで定義）、データを取得するための関数
	const fetchMoreData: () => Promise<void> = async () => {
		// データを取得する関数
		const newPosts: Post[] = await fetchPosts(offset);
        //取得したデータでステート更新
		setPosts((prev: Post[]) => [...prev, ...newPosts]);
        //次のデータ取得に向けてオフセットを更新
		setOffset((prev) => prev + 5);
        //取得したデータがなければhasMoreをfalseに
		if (newPosts.length === 0) setHasMore(false);
	};
	const { isFetching } = useInfiniteScroll(fetchMoreData, hasMore);

	//初回データ取得を行う
	useEffect(() => {
		fetchMoreData();
	}, []);
	return (
		<div className="bg-black min-h-screen w-[90%] m-auto" id="scroll">
			{posts.map((post: Post) => (
				<div
					key={post.id}
					className="text-4xl min-h-[180px] mt-10 bg-gray-900 text-white p-10 rounded-md">
					{post.title}
				</div>
			))}
			{isFetching && (
				<div className="text-white mt-10 pb-10 text-center text-4xl">
					ロード中
				</div>
			)}
		</div>
	);
}

```
## 取得状態を管理するカスタムフック
```useInfiniteScroll.ts
import { useState, useEffect, useCallback } from "react";

const useInfiniteScroll = (
	fetchMoreData: () => Promise<void>,
	hasMore: boolean
) => {
	const [isFetching, setIsFetching] = useState(false);

	const handleScroll = useCallback(() => {
		if (
			//画面の高さ+スクロール量がコンテンツの大きさと異なる(上回った)場合true
			window.innerHeight + document.documentElement.scrollTop !==
				document.documentElement.offsetHeight ||
			//取得中ならtrue
			isFetching ||
			//まだ取得可能かどうか
			!hasMore
		) {
			return;
		}
		setIsFetching(true);
	}, [isFetching, hasMore]);

	useEffect(() => {
		//取得が必要な場合、取得処理を実行する。
		if (!isFetching) return;
		fetchMoreData().then(() => {
			setIsFetching(false);
		});
	}, [isFetching, fetchMoreData]);

	useEffect(() => {
		window.addEventListener("scroll", handleScroll);
		//データを取得しきった際はスクロール時にapiをコールしたくないためクリーンナップ関数を定義
		return () => window.removeEventListener("scroll", handleScroll);
	}, [handleScroll]);

	return { isFetching };
};

export default useInfiniteScroll;

```

```layout.tsx
import "./globals.css";

export default function RootLayout({
	children,
}: {
	children: React.ReactNode;
}) {
	return (
		<html lang="en">
			<body className="bg-black">{children}</body>
		</html>
	);
}

```