Reactで無限スクロールを実装する。(Handson,ライブラリなし)

## はじめに
表題通り、Reactで無限スクロールを実装します。ライブラリは利用せず`IntersectionObserver`を活用して作成しました。

## 成果物

https://twitter.com/kkfactory0705/status/1718159737286709594

https://github.com/kouji0705/react_infinite_scroll

### ソースコード
```shell:ディレクトリ構成
~/develop/HITOTSU/react_infinity_scroll$ tree -I node_modules 
.
├── README.md
├── package.json
├── public
│   ├── favicon.ico
│   ├── index.html
│   ├── logo192.png
│   ├── logo512.png
│   ├── manifest.json
│   └── robots.txt
├── src
│   ├── App.tsx
│   ├── hooks
│   │   └── useInfinityScroll.tsx
│   ├── index.tsx
│   └── logo.svg
├── tsconfig.json
└── yarn.lock

4 directories, 14 files
```

```tsx:index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);
```

```tsx:App.tsx
import React, { useRef } from 'react';
import { useInfinityScroll } from './hooks/useInfinityScroll'

function App() {
  const containerRef = useRef(null);

  const fetchData = async (page: number) => {
    const response = await fetch(`https://jsonplaceholder.typicode.com/posts?_page=${page}`);
    const data = await response.json();
    return data;
  };

  const data: Post[] = useInfinityScroll(containerRef, fetchData);

  return (
    <div>
      <div style={{height: '2000px'}}>無限スクロール開始</div>
      <div ref={containerRef}>
        {data.map((item: Post) => (
          <div key={item.id}>
            <p>
              {item.id}：{item.title}
            </p>
          </div>
        ))}
      </div>
    </div>
  );
}

export default App;

interface Post {
  userId: number;
  id: number;
  title: string;
  body: string;
}

```

```tsx:src/hooks/useInfinityScroll.tsx
import {RefObject, useCallback, useEffect, useState} from 'react';

const options ={
    root: null, // ルート要素 (viewport) を使用
    rootMargin: '0px',
    threshold: 0, // 要素が少しでもビューポートに表示された瞬間からコールバックが呼び出される
}

export const useInfinityScroll = <T,>(ref: RefObject<HTMLElement | null>, fetch: (page: number) => Promise<T[]>) => {
  const [data, setData] = useState<T[]>([]);
  const [page, setPage] = useState(1);
  const [isLoading, setIsLoading] = useState(false); // 読み込み中のフラグ
  const [hasMoreData, setHasMoreData] = useState(true); // 追加データがあるかどうかを追跡

  const scrollObserver = useCallback(
    () =>
      new IntersectionObserver((entries) => {
        console.log('entries', entries);
        entries.forEach((entry) => {
          if (entry.isIntersecting && !isLoading && hasMoreData) {
            setIsLoading(true); // 読み込み中フラグを設定
            fetch(page).then((_data) => {
              console.log('fetch call page', page);
              if (_data.length > 0) {
                setPage(page + 1);
                setData((oldValue) => [...oldValue, ..._data]);
              } else {
                // 追加データがない場合
                setHasMoreData(false);
              }
              setIsLoading(false);
            });
          }
        });
      },options),
    [page, fetch, isLoading, hasMoreData]
  );
  useEffect(() => {
    const target = ref.current;
    if (target) {
      const observer = scrollObserver();
      observer.observe(target);
      return () => {
        observer.unobserve(target);
      };
    }
  }, [scrollObserver, ref]);

  return data;
};

```