React+Apolloで無限スクロールを実装した時の備忘録を残しておこう。
無限スクロールそのものより、GraphQLのページネーションの実装で考えることが多い気がする。
↓実装イメージ
1.前提知識・条件
1-1.実装環境やライブラリ
クライアントはReact(Javascript)、サーバー側はHasuraでpostgreSQLと繋げるようにし、クライアント側のGraphQLの取り回しはApolloを使用。
"react": "^17.0.2
Hasura v1.3.3
無限スクロールのコンポーネントはreact-infinite-scroll-componentを使用
"react-infinite-scroll-component": "^6.1.0"
1-2.無限スクロールとは
無限スクロールは、Googleの検索結果のようなページ遷移ではなく、スクロールで次々とコンテンツを表示させるUIのこと。Twitterとかインスタみたいな感じ。
全てのコンテンツを表示するのではなく、一定量のコンテンツを表示してページ下部に到達した時(する前)に、次のコンテンツをロードする必要がある。
1-3.2種類のページネーションについて
一定量のコンテンツの表示(ページネーション)実装はoffset-basedとcursor-basedの2種類に大別できる。
offset-based
LimitとOffsetを指定して、データ取得する度にOffsetの数を増やしてデータ取得範囲を伸ばしていくイメージ。実装は容易だけどデータ量が増えるとデータを見る量が増えてパフォーマンスが悪くなるらしい。SQLのLIMITとOFFSET使う感じ。
cursor-based
データ取得範囲の最初と最後を指定して、データ取るイメージ。SQLで言うならWhere句でデータを絞り込む感じ。GraphQLでやる場合、取得するデータに一意かつ順番があるカラム(unique,sequentialなカラム)を持たせる必要があるっぽい。
この辺の詳しい解説は↓の記事がめっちゃ参考になる。
cursor-basedの中にさらにrelay-style-paginationという手法があり、ApolloだとInMemoryCacheでrelayStylePagination()を指定するといい感じにできるらしいが、IDの振り方とかで詰まったのでここではスルー。
1-4.ApolloのfetchMoreについて
fetchMoreはuseQueryフックに含まれる関数で、useQueryのクエリを引数だけ変えて実行したい時に使う。
スクロールで次のコンテンツをロードする度にfetchMoreを実行する感じ。
1-5.react-infinite-scroll-componentについて
スクロールさせたい要素をタグ直下の子要素として配置する。
↓使う時のイメージ
<InfiniteScroll
//表示する子要素の数(スクロールする度に増やす必要がある)
dataLength={items.length}
//ページ下部に到達した時に実行する関数を指定
next={fetchData}
//データ取得がを続けるかどうかを指定。
//trueだとページ下部に到達した時にnextで指定した関数が実行さる。
//falseだと実行されず、endMessageが表示される。
hasMore={true}
//nextの関数結果が返ってくるまで表示するう要素を指定
loader={<h4>Loading...</h4>}
//hasMoreがfalseの時に表示する要素を指定
endMessage={<p>You have seen it all</p>}
>
//スクロールさせたい要素を列挙
{sampleData.map((v) =>
<sampleDataChild key={v.key}/>)}
</InfiniteScroll>
2.実装
2-1.GraphQlのクエリ
書籍情報を取得するクエリ
limitとoffsetは引数で適宜指定できる。
const GET_BOOKS = gql`
query getBooks(
$limit_number: Int
$offset_number: Int
) {
books(
limit: $limit_number
offset: $offset_number
order_by: { updated_at: desc }
) {
title
isbn
author
image_path
price
publish_date
}
}
`;
2-2.stateの管理やuseQueryの設定
ロードする度に12件づつ取得するイメージ
const BookLists = React() => {
//取得したデータを格納
const [bookData, setBookData] = useState([]);
//オフセット数値を格納
const [offset, setOffset] = useState(0);
//スクロール後のデータフェッチを続けるかの判定を格納
const [hasMore, setHasMore] = useState(true);
//fetchMore取得
const { fetchMore } = useQuery(GET_BOOKS,
onCompleted: (data) => {
if (!data.books.length) {
const { books } = data.books;
setBookData(books);
}
},
};
//infiniteScrollのnextで呼ぶ関数を定義
const getBooksData = async () => {
//呼ぶ度にoffsetに12加算
setOffset((prevOffset) => prevOffset + 12);
const { data } = await fetchMore({
variables: { limit_number: 12, offset_number: offset },
});
// 取得したdata件数が0の時はhasMoreをfalseにして、スクロールを終了
data.books.length === 0 && setHasMore(!hasMore);
setBookData((prev) => [...prev, ...data.books]);
};
//初回画面表示に使用
useEffect(() => {
getBooksData();
}, []);
return (
<div>
{bookData && bookData.length > 0 && (
<BookContents
data={bookData}
nextFunc={() => getBooksData()}
hasMore={hasMore}
/>
)}
</div>
2-3.InfiniteScrollのコンポーネント設定
2-2で設定した内容をInfiniteScrollで適宜呼び出す。
const InfiniteBooks = (props) => {
return (
<InfiniteScroll
//InfiniteScroll直下の要素配置を変えたい時に指定
style={{ display: 'flex', flexWrap: 'wrap' }}
dataLength={booksData.length}
next={() => props.nextFunc()}
hasMore={props.hasMore}
//material-uiのローダーを入れてる
loader={<CircularProgress />}
endMessage={
<h1 style={{ textAlign: 'center' }}>
<b>You have seen it all</b>
</h1>
}
>
{booksData &&
booksData.map((v) =>
<BooksChild key={v.title} props={v} />)}
</InfiniteScroll>
);
};
3.参考
↓参考にしたcode sandbox
リック・アンド・モーティというアニメのキャラを延々とスクロールできる。