☀️

SWR活用パターン 無限スクロール編 (+ graphql-request, 交差オブザーバー APIカスタムフック)

2023/03/24に公開

できたもの

動作的には、uhyo 氏の以下の記事で紹介されたいたものと同じである。

Recoil selector活用パターン 無限スクロール編

https://zenn.dev/uhyo/articles/recoil-selector-infinite-scroll

  • サーバーから取得したデータがリストで表示されている。
  • ユーザーがスクロールしてリストの下に到達したら、サーバーから追加のデータを読み込んでリストの下に継ぎ足す。[1]

uhyo 氏の例と同じく、今回も一回50件ずつ取得している。

GitHub リポジトリ
https://github.com/mumei-xxxx/swr-infinite-loading-IntersectionObserver-sample

infinite_loading

この記事の要旨

  • SWR には、無限ローディング(無限ローディング)を実現することを想定した API がある。
    • それは、useSWRInfinite である。
    • ある意味、無限ローディング専用の API なので useSWRInfinite を使うと、シンプルに無限ローディングが実装できる。
    • useSWRInfinite では、GraphQL も利用可能。
  • 無限ローディングで、交差オブザーバー API (Intersection Observer API) を利用するときには、カスタムフックを作ると便利である。
    • 具体的には、交差が起きると true を返すようなカスタムフックを作る。
  • 無限ローディングを実装するには、交差オブザーバー API のカスタムフックが true を返したときに、React の useEffect を使って、useSWRInfinite で新しいページのリクエストを走るようにすればよい。

この記事を書こうと思った経緯

uhyo 氏の「Recoil selector 活用パターン 無限スクロール編」を読んだ。
そして、漠然とだが、SWR で作ったらどうなるのだろうかと思った。
そこで、作ってみた、というのがこの記事である。

最初は、あまり調べずに我流で作っていた。
だが、Twitter で、「swr のドキュメントに全てが書いてある」のようなツイートを見かけたことを思い出し、SWR の公式ドキュメントを読んでみた。

https://twitter.com/myuon_myon/status/1585266771392544768

そうしたら、公式に「無限ローディングには、useSWRInfinite という API を使え」と書いているではないか。
API 名からして、useSWR≪Infinite(無限)≫である。

https://swr.vercel.app/ja/docs/pagination

SWR は、ページネーションや無限ローディングなどの一般的な UI パターンをサポートするための専用 API である useSWRInfinite を提供しています。

infinite-loading 公式の GitHub issue のタイトルを取得するサンプル

また、「useSWRInfinite SWR」とかでググると、Zenn や個人ブログにも記事があった。

SWRのuseSWRInfinite()を使って無限ローディングを実装する りーほーブログ

useSWRInfiniteを使うとページング(無限ローディング)の処理がシンプルに書けて気持ちいい! Zenn

この時点で、「もう記事があるから、書かなくてもいいか……」と思った。しかし、

  • useSWRInfinite で GraphQL を使う。
  • ✅ 交差オブザーバー API (Intersection Observer API) を最新ページ読み込みのトリガーとする。

のふたつを満たすものが、私の調べる限りなかったので、やはり作ってみることにした。
また、「記事を読んで理解する」のと、「自分で実際に動くものが作れる」は、違うので、
自分で実際に動くものと作ってみようという意味もあった。

なお、「無限スクロール」「無限ローディング」などの名称があるが、
この記事では、「無限ローディング」で呼び方を固定する。

バージョン情報/環境

バージョン情報/環境
Windows Home 11 / WSL / Ubuntu 20.04
Node.js 18.13.0
pnpm 7.27.1
graphql 16.6.0
graphql-request 5.1.0
react 18.2.0
react-dom 18.2.0
swr 2.0.3

ディレクトリ構成

ディレクトリ構成
.
├── dist
├── index.html
├── package.json
├── personal.code-workspace
├── pnpm-lock.yaml
├── public
├── src
│   ├── App.css
│   ├── App.tsx
│   ├── features
│   │   └── inifiniteLoading
│   │       ├── components
│   │       │   ├── Loading
│   │       │   │   └── index.tsx
│   │       │   └── PokemonList
│   │       │       └── index.tsx
│   │       ├── customHooks
│   │       │   ├── useIsIntersecting.ts
│   │       │   └── usePokemonListSWRInfinite.ts
│   │       └── useCases
│   │           └── formattedPokemonListQuery.ts
│   ├── index.css
│   ├── main.tsx
│   └── vite-env.d.ts
├── tsconfig.eslint.json
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts

いかにして無限ローディングを実現するか

基本事項の確認 PokeAPI の仕様

まず、対象となる ポケモンの API、PokeAPI の仕様について確認したい。

これは、公式の、Resource Lists/Pagination (group) に載っていた。

https://pokeapi.co/docs/v2

まず、一回のリクエストで取得するリソース数(件数)、これは、limit で指定できる。

そして、ポケモンの ID の何番から、取得するか。
これは、offset で、取得したい ID の直前の数値を指定する。

例えば、ポケモン ID の151番から、30件取得したいとする。
その場合、offset には150、limit には30を指定して、リクエストすればよい。

実装の考え方 無限ローディングをページネーションと捉える

実装の考え方は、無限ローディングを一種のページネーションと考えることだ。
ユーザーがスクロールしてリストの下に到達したら、ポケモンのデータを50件ずつ取得する。

つまり、
1ページ目 0-50
2ページ目 51-100
3ページ目 101-150
……

のようになる。

pagination_01

ここで、最初の1ページを index の0と考える。
すると、offset (取得する直前の ID)は、そこからの index に取得したい件数(今回は50件)を掛けた値である。
図にすると以下のようになる。

pagination_offset

したがって、無限ローディングを実現するには、

  • ページの index (最初の1ページを0とする)
  • ページの index を +1 する関数

が必要だ。

以上を実現するのが、SWR の useSWRInfinite である。

useSWRInfinite API の引数と戻り値
import useSWRInfinite from 'swr/infinite'
 
// ...
const { data, error, isLoading, isValidating, mutate, size, setSize } = useSWRInfinite(
  getKey, fetcher?, options?
)

https://swr.vercel.app/ja/docs/pagination#useswrinfinite

ここで SWR 自体については、公式サイトの解説や以前私の書いた記事を参照されたい。

https://swr.vercel.app/ja

https://zenn.dev/purenium/articles/nextjs-sg-use-swr-strong-consistency#課題の解決-%2F-swr-と-useswr-について

以下、簡単に useSWRInfinite の API の説明をする。
引数の getKey は、ページのインデックスと前ページのデータを受け取る関数で、ページのキーを返す。
ページのキーとは、fetcher にわたされる API エンドポイントなどのデータである。
getKey 内で、受け取るページのインデックスを増やすには、戻り値の sizesetSize を使う。
size は、フェッチして返されるだろうページ数。
setSize は、フェッチする必要のあるページ数を設定する。
sizegetKey 内で受け取れるインデックスは連動している。
getKey 関数内で受け取るインデックスをインクリメント、つまり、1 増やすには、フェッチする必要のあるページ数を 1 増やせばよい。
コードで言うと、setSize(size + 1) とすればよい。

ここで、「いかにして無限ローディングを実現するか」についてまとめる。

まず、データの取得については、getKey でインデックス0のページのデータ、インデックス1のページぞれぞれのデータを動的に取得する。
インデックスをインクリメントするには、setSize(size + 1) を実行する。
setSize(size + 1) を実行するトリガーは、Loading コンポーネントが表示されることである。
表示の判定は、JavaScript の交差オブザーバー API を使い、setSize(size + 1) の実行は、React の useEffect を使う。
これによって、新しいページのリクエストが走る。
というのが一連の流れになる。

以上を踏まえて、実際のコードを見ていこう。

useSWRInfinite カスタムフック(ポケモンデータを取得)

src/features/inifiniteLoading/customHooks/usePokemonListSWRInfinite.ts
import { request } from 'graphql-request'
import useSWRInfinite from 'swr/infinite'

import {
  convertRawResultToPokemon,
  type Pokemon
} from '@/features/inifiniteLoading/useCases/formattedPokemonListQuery'

/**
 * @typedef API のレスポンス
 */
export interface QueryResult {
  species: ReadonlyArray<{
    readonly name: string
    readonly id: number
    readonly pokemon_v2_pokemonspeciesnames: ReadonlyArray<{
      language_id: 9 | 11
      name: string
    }>
  }>
}

/**
 * @description GraphQL のクエリ
 */
const query = `
  query ($offset: Int!, $limit: Int!) {
    species: pokemon_v2_pokemonspecies(
      where: {}
      order_by: { id: asc }
      offset: $offset
      limit: $limit
    ) {
      name
      id
      pokemon_v2_pokemonspeciesnames(where: { language_id: { _in: [9, 11] } }) {
        language_id
        name
      }
    }
  }
`

/**
 * @typedef GraphQL のクエリの変数の型
 */
interface PokemonGraphqlVariables {
  offset: number
  limit: number
}

/**
 * @typedef getKey の返り値の型
 */
type ApiKeyType = [string, PokemonGraphqlVariables]

/**
 * @description 1ページあたりに取得するポケモンデータの件数
 */
const MAX_NUMBER_OF_ENTRY_BY_QUERY = 50

/**
 * @description useSWRInfinite の引数の getKey
 */
const getKey = (
  pageIndex: number,
  previousPageData: Pokemon[] | null
): ApiKeyType | null => {
  if (previousPageData !== null && previousPageData.length === 0) {
    return null
  }

  const pageStartPoint = pageIndex * MAX_NUMBER_OF_ENTRY_BY_QUERY

  return [
    query,
    {
      offset: pageStartPoint,
      limit: MAX_NUMBER_OF_ENTRY_BY_QUERY
    }
  ]
}

/**
 * @description useSWRInfinite の引数の fetcher
 */
const fetcher = async (
  query: string,
  { offset: offsetVal, limit: limitVal }: PokemonGraphqlVariables
): Promise<Pokemon[]> => {
  const response = await request<QueryResult>(
    'https://beta.pokeapi.co/graphql/v1beta',
    query,
    {
      offset: offsetVal,
      limit: limitVal
    }
  )

  return convertRawResultToPokemon(response)
}

/**
 * @typedef usePokemonListSWRInfinite の戻り値の型
 */
interface UsePokemonListSWRInfiniteReturnType {
  pokemonsDataArr: Array<readonly Pokemon[]>
  error: Error | undefined
  isLoading: boolean
  loadMorePage: () => void
  isReachingEnd: boolean
}

/**
 * @description
 * useSWRInfinite でポケモンデータを取得する
 * ページサイズをインクリメントする関数を返す
 * など。
 */
export const usePokemonListSWRInfinite =
  (): UsePokemonListSWRInfiniteReturnType => {
    const { data, error, isLoading, size, setSize } = useSWRInfinite<
      Pokemon[],
      Error
    >(
      getKey,
      async ([query, pokemonGraphqlVariables]: ApiKeyType) =>
        await fetcher(query, pokemonGraphqlVariables),
      { revalidateFirstPage: false }
    )

    // すべてのデータを取得したら、true
    const isReachingEnd: boolean =
      data !== undefined &&
      data[data.length - 1]?.length < MAX_NUMBER_OF_ENTRY_BY_QUERY

    const emptyData: Array<readonly Pokemon[]> = []

    const pokemonsDataArr: Array<readonly Pokemon[]> =
      typeof data === 'undefined' ? emptyData : data

    // ページを増やす => 最新のページを読み込むtrigger
    const loadMorePage = (): void => {
      void setSize(size + 1)
    }

    return {
      pokemonsDataArr,
      error,
      isLoading,
      loadMorePage,
      isReachingEnd
    }
  }

以下、ポイントごとに解説する

getKey

src/features/inifiniteLoading/customHooks/usePokemonListSWRInfinite.ts
/**
 * @typedef getKey の返り値の型
 */
type ApiKeyType = [string, PokemonGraphqlVariables]

/**
 * @description 1ページあたりに取得するポケモンデータの件数
 */
const MAX_NUMBER_OF_ENTRY_BY_QUERY = 50

/**
 * @description useSWRInfinite の引数の getKey
 */
const getKey = (
  pageIndex: number,
  previousPageData: Pokemon[] | null
): ApiKeyType | null => {
  if (previousPageData !== null && previousPageData.length === 0) {
    return null
  }

  const pageStartPoint = pageIndex * MAX_NUMBER_OF_ENTRY_BY_QUERY

  return [
    query,
    {
      offset: pageStartPoint,
      limit: MAX_NUMBER_OF_ENTRY_BY_QUERY
    }
  ]
}

上にも書いたように、getKey はページのインデックスと前ページのデータを受け取る関数で、ページのキーを返す関数である。
返された値は、後述する fetcher に渡される。
公式ドキュメントにもあるように、null を返した場合は、fetcher でのリクエストは行われない。
ここでは、読み込むデータが、もうない(データを読み込み、ポケモンリストの最後に到達した)場合、に null を返すようにしている。

ページのインデックスは、pageIndex だ。ここが、先に書いた、size と連動している部分である。
MAX_NUMBER_OF_ENTRY_BY_QUERY は、1ページで取得するポケモンの件数だ。
offset(取得開始したい ID の直前の ID)を、ここでは、pageStartPoint としている。
pageStartPoint は、上の図でも解説した通り、

const pageStartPoint = pageIndex * MAX_NUMBER_OF_ENTRY_BY_QUERY

となる。

fetcher

src/features/inifiniteLoading/customHooks/usePokemonListSWRInfinite.ts
/**
 * @description useSWRInfinite の引数の fetcher
 */
const fetcher = async (
  query: string,
  { offset: offsetVal, limit: limitVal }: PokemonGraphqlVariables
): Promise<Pokemon[]> => {
  const response = await request<QueryResult>(
    'https://beta.pokeapi.co/graphql/v1beta',
    query,
    {
      offset: offsetVal,
      limit: limitVal
    }
  )

  return convertRawResultToPokemon(response)
}

先述の getKey で返された値が渡される。
なお、uhyo 氏のサンプルでは、GraphQL のリクエストに、urql を使用していらした。
urql も SWR もキャッシュ機能をもつ。
データを二重にキャッシュする必要性はないため、GraphQL クライアントには、graphql-request を採用した。

そうすると、GraphQL のリクエストは、

src/features/inifiniteLoading/customHooks/usePokemonListSWRInfinite.ts
const response = await request<QueryResult>(
  'https://beta.pokeapi.co/graphql/v1beta',
  query,
  {
    offset: offsetVal,
    limit: limitVal
  }
)

のようになる。
offset: offsetVal が動的に変化していく部分だ。
そして、これで得られた response は、型 QueryResult だ。
この response を型 QueryResult から、型 Pokemon[] に整形する。
これが、convertRawResultToPokemon(response) の部分だ。
convertRawResultToPokemon 自体は uhyo 氏の元のコードと同じだ。
便宜のため、以下にも記載する。

`convertRawResultToPokemon` 全体のコードを click で表示(src/features/inifiniteLoading/useCases/formattedPokemonListQuery.ts)
src/dataflow/formattedPokemonListQuery.ts
import { type QueryResult } from '@/features/inifiniteLoading/customHooks/usePokemonListSWRInfinite'

/**
 * @typedef response 整形後の型
 */
export interface Pokemon {
  id: number
  en: string
  ja: string
}

/**
 * @description response を整形
 */
export function convertRawResultToPokemon(response: QueryResult): Pokemon[] {
  return response.species.map((species) => {
    const en =
      species.pokemon_v2_pokemonspeciesnames.find((n) => n.language_id === 9)
        ?.name ?? ''
    const ja =
      species.pokemon_v2_pokemonspeciesnames.find((n) => n.language_id === 11)
        ?.name ?? ''
    return {
      id: species.id,
      en,
      ja
    }
  })
}

ここで、fetcher が返す、データは以下のようになる。

fetcher が返す配列
[
  {
      "id": 1,
      "en": "Bulbasaur",
      "ja": "フシギダネ"
  },
  {
      "id": 2,
      "en": "Ivysaur",
      "ja": "フシギソウ"
  },
  {
      "id": 3,
      "en": "Venusaur",
      "ja": "フシギバナ"
  },
  ……(中略)
  {
      "id": 49,
      "en": "Venomoth",
      "ja": "モルフォン"
  },
  {
      "id": 50,
      "en": "Diglett",
      "ja": "ディグダ"
  }
],
// 1ページ目の配列 ↑↑

usePokemonListSWRInfiniteuseSWRInfiniteのカスタムフック)ポケモンデータを取得

src/features/inifiniteLoading/customHooks/usePokemonListSWRInfinite.ts
/**
 * @typedef usePokemonListSWRInfinite の戻り値の型
 */
interface UsePokemonListSWRInfiniteReturnType {
  pokemonsDataArr: Array<readonly Pokemon[]>
  error: Error | undefined
  isLoading: boolean
  loadMorePage: () => void
  isReachingEnd: boolean
}

/**
 * @description
 * useSWRInfinite でポケモンデータを取得する
 * ページサイズをインクリメントする関数を返す
 * など。
 */
export const usePokemonListSWRInfinite =
  (): UsePokemonListSWRInfiniteReturnType => {
    const { data, error, isLoading, size, setSize } = useSWRInfinite<
      Pokemon[],
      Error
    >(
      getKey,
      async ([query, pokemonGraphqlVariables]: ApiKeyType) =>
        await fetcher(query, pokemonGraphqlVariables),
      { revalidateFirstPage: false }
    )

    // すべてのデータを取得したら、true
    const isReachingEnd: boolean =
      data !== undefined &&
      data[data.length - 1]?.length < MAX_NUMBER_OF_ENTRY_BY_QUERY

    const emptyData: Array<readonly Pokemon[]> = []

    const pokemonsDataArr: Array<readonly Pokemon[]> =
      typeof data === 'undefined' ? emptyData : data

    // ページを増やす => 最新のページを読み込むtrigger
    const loadMorePage = (): void => {
      void setSize(size + 1)
    }

    return {
      pokemonsDataArr,
      error,
      isLoading,
      loadMorePage,
      isReachingEnd
    }
  }

useSWRInfinite 部分

const { data, error, isLoading, size, setSize } = useSWRInfinite<
  Pokemon[],
  Error
>(
  getKey,
  async ([query, pokemonGraphqlVariables]: ApiKeyType) =>
    await fetcher(query, pokemonGraphqlVariables),
  { revalidateFirstPage: false }
)

useSWRInfinite のリクエスト部分。
useSWRInfinite<Pokemon[], Error> とすることで、返り値の dataerror に型の指定ができる。
この場合、data の型は、Array<Pokemon[]> | undefined に、
error の型は、Error | undefined になる。

async ([query, pokemonGraphqlVariables]: ApiKeyType) => の部分について。
引数が、複数のときは、このように配列で渡す。
(ドキュメント 複数の引数参照 https://swr.vercel.app/ja/docs/arguments#複数の引数

{ revalidateFirstPage: false } は、「常に最初のページを再検証する」設定をオフにしている。

ここで、 data の戻る。
data は、ページごとの複数の API レスポンスの配列になる。
つまり、以下のような二重配列になる。

useSWRInfinite の返り値の data
[
   // 1ページ目の配列 ↓↓
   [
      {
         "id": 1,
         "en": "Bulbasaur",
         "ja": "フシギダネ"
      },
      {
         "id": 2,
         "en": "Ivysaur",
         "ja": "フシギソウ"
      },
      {
         "id": 3,
         "en": "Venusaur",
         "ja": "フシギバナ"
      },
      ……(中略)
      {
         "id": 49,
         "en": "Venomoth",
         "ja": "モルフォン"
      },
      {
         "id": 50,
         "en": "Diglett",
         "ja": "ディグダ"
      }
   ],
   // 1ページ目の配列 ↑↑
   // 2ページ目の配列 ↓↓
   [
      {
         "id": 51,
         "en": "Dugtrio",
         "ja": "ダグトリオ"
      },
      {
         "id": 52,
         "en": "Meowth",
         "ja": "ニャース"
      },
      ……(中略)
      {
         "id": 99,
         "en": "Kingler",
         "ja": "キングラー"
      },
      {
         "id": 100,
         "en": "Voltorb",
         "ja": "ビリリダマ"
      }
   ],
   // 2ページ目の配列 ↑↑
   // 3ページ目の配列 ↓↓
   [
      {
         "id": 101,
         "en": "Electrode",
         "ja": "マルマイン"
      },
      {
         "id": 102,
         "en": "Exeggcute",
         "ja": "タマタマ"
      },
      ……(中略)
      {
         "id": 149,
         "en": "Dragonite",
         "ja": "カイリュー"
      },
      {
         "id": 150,
         "en": "Mewtwo",
         "ja": "ミュウツー"
      }
   ]
   // 3ページ目の配列 ↑↑
]
// すべてのデータを取得したら、true
const isReachingEnd: boolean =
  data !== undefined &&
  data[data.length - 1]?.length < MAX_NUMBER_OF_ENTRY_BY_QUERY

const emptyData: Array<readonly Pokemon[]> = []

const pokemonsDataArr: Array<readonly Pokemon[]> =
  typeof data === 'undefined' ? emptyData : data

// ページを増やす => 最新のページを読み込むtrigger
const loadMorePage = (): void => {
  void setSize(size + 1)
}
  • isReachingEnd : すべてのデータを取得したら、true
    • すべてのデータを取得し終わったら、最後の API レスポンスの配列の要素数は、MAX_NUMBER_OF_ENTRY_BY_QUERY より小さくなるので、その条件で判定。
  • pokemonsDataArr : ポケモンデータ
    • typeof data === 'undefined' のときは、空の配列となるようする。
  • loadMorePage : ページサイズをインクリメントする関数。
    • 後述する、Loading コンポーネント内で使用する。

以上で、usePokemonListSWRInfinite.ts の説明を終える。

Loading コンポーネント

Loading コンポーネント から、usePokemonListSWRInfinite までの処理の流れは、

ローディングが表示
=> フェッチして返されるであろうページをインクリメント
=> getKey が返す、API のキーが変化 =>
=> 新規 API リクエスト

である。

前半の「ローディングが表示 => フェッチして返されるであろうページをインクリメント」が、Loading コンポーネントで行う。
全体のコードは以下。

src/features/inifiniteLoading/components/Loading/index.tsx
import { type FC, useEffect, useRef } from 'react'

import { useIsIntersecting } from '@/features/inifiniteLoading/customHooks/useIsIntersecting'
import { usePokemonListSWRInfinite } from '@/features/inifiniteLoading/customHooks/usePokemonListSWRInfinite'

/**
 * @description
 * Loading component.
 * viewport と交差したとき、ポケモンAPIを実行するtriggerとなる。
 */
export const Loading: FC = () => {
  const observedRef = useRef<HTMLParagraphElement | null>(null)
  const { loadMorePage, isReachingEnd } = usePokemonListSWRInfinite()

  // トリガーが表示されているか監視
  const isIntersection: boolean =
    useIsIntersecting<HTMLParagraphElement>(observedRef)

  useEffect(() => {
    // トリガーが表示されたらデータを取得
    if (isIntersection && !isReachingEnd) {
      loadMorePage()
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isIntersection, isReachingEnd])

  return <p ref={observedRef}>Loading...</p>
}

大きな流れとしては、
ローディングが表示されるとき、isIntersection が、true になり、
isIntersection に依存している、useEffect で、loadMorePage() が実行される。
isIntersection の判定は、交差判定するカスタムフックを使用している。

交差判定カスタムフック

要素が表示されると、true を返すカスタムフックは、りーほーブログさんの記事を参考にさせていただいた。
以下のように、引数に監視対象の要素を取る。
監視対象の要素が表示されると true を返す。
独自の工夫としては、ジェネリクス(RefElement)を使い、
監視対象の要素の型を、カスタムフックを呼び出すときに指定できるようした。

src/features/inifiniteLoading/customHooks/useIsIntersecting.ts
import { useEffect, useState } from 'react'

/**
 * @description 要素に監視対象のDOMを取る。
 * 監視対象のDOMが画面に表示されると、true を返す。
 */
export const useIsIntersecting = <RefElement extends HTMLElement>(
  ref: React.MutableRefObject<RefElement | null>
): boolean => {
  const [isIntersecting, setIsIntersecting] = useState(false)

  useEffect(() => {
    if (ref.current == null) return

    const observer = new IntersectionObserver(
      ([entry]) => {
        setIsIntersecting(entry.isIntersecting)
      },
      {
        rootMargin: '0px 0px 100px 0px'
      }
    )

    let observerRefCurrent: RefElement | null = null

    // 監視を開始
    observer.observe(ref.current)
    observerRefCurrent = ref.current

    return () => {
      // 要素の監視を終了する
      observer.unobserve(observerRefCurrent as RefElement)
    }
  })

  return isIntersecting
}

ポケモンデータ表示コンポーネント

ポケモンデータの表示のコンポーネントは以下のようになる。
基本的には、uhyo 氏の元記事のコードの流用だ。

変えた部分は、

  • ロード表示とエラー表示を追加
  • データの二重配列に対応 => map を二重にした
  • isReachingEnd 真偽値を使い。データをすべて取得し終わったあとに、メッセージを表示

などだ。

src/features/inifiniteLoading/components/PokemonList/index.tsx
import { type FC, Fragment } from 'react'

import { Loading } from '@/features/inifiniteLoading/components/Loading'
import { usePokemonListSWRInfinite } from '@/features/inifiniteLoading/customHooks/usePokemonListSWRInfinite'

/**
 * @description ポケモンのデータを表示する。
 */
export const PokemonList: FC = () => {
  const { pokemonsDataArr, isLoading, error, isReachingEnd } =
    usePokemonListSWRInfinite()

  if (error != null) {
    return <>Error: {error}</>
  }

  if (isLoading || pokemonsDataArr.length === 0) {
    return <>loading……(ロード中)</>
  }

  return (
    <Fragment>
      <dl>
        {pokemonsDataArr.map((pokemons) => {
          return pokemons.map((pokemon) => (
            <div key={pokemon.id}>
              <dt lang="ja">{pokemon.ja}</dt>
              <dd>
                {pokemon.en} <span>#{pokemon.id}</span>
              </dd>
            </div>
          ))
        })}
      </dl>
      {!isReachingEnd && <Loading />}
      {isReachingEnd && (
        <p style={{ margin: '10px 0 10px' }}>
          All data loading is complete.
          (すべてのデータの読み込みが完了しました。)
        </p>
      )}
    </Fragment>
  )
}

まとめ

ここでまとめを考えたが、記事の前半に記載した「要旨」以上のものが思い浮かばないため、再掲する。

  • SWR には、無限ローディング(無限ローディング)を実現することを想定した API がある。
    • それは、useSWRInfinite である。
    • ある意味、無限ローディング専用の API なので useSWRInfinite を使うと、シンプルに無限ローディングが実装できる。
    • useSWRInfinite では、GraphQL も利用可能。
  • 無限ローディングで、交差オブザーバー API (Intersection Observer API) を利用するときには、カスタムフックを作ると便利である。
    • 具体的には、交差が起きると true を返すようなカスタムフックを作る。
  • 無限ローディングを実装するには、交差オブザーバー API のカスタムフックが true を返したときに、React の useEffect を使って、useSWRInfinite で新しいページのリクエストを走るようにすればよい。

感想

専用の API を使っているので、当然と言ったら当然なのだが、それほど難しいことをしなくても、無限ローディングが実現できたので満足している。

脚注
  1. 「Recoil selector 活用パターン 無限スクロール編」より引用 ↩︎

Discussion