header image

枝折

Next.js v13 でも無限ローディングやる、React Server Components 使います

CREATED: 2023 / 08 / 06 Sun

UPDATED: 2023 / 08 / 06 Sun

React Server Components と組み合わせてうまい具合に無限ローディングやるのどうすれば良いかなと思いながらやってみました。

とりあえず無限ローディング

プロジェクト作る。

$ npx create-next-app@latest
$ cd project/
$ npm install @tanstack/react-query \
  && react-infinite-scroller \
  && @types/react-infinite-scroller \
  && react-loader-spinner

TanStack Query で API からデータとってきて react-infinite-scroller を無限ローディングする感じです。 react-loader-spinner は無限ローディング下部に表示するスピナーに利用します。

/pokemons にポケモンの一覧を表示して無限スクロールします。 あ、もちろん今回も PokeAPI を使います。ポケモン好きなので。

$ mkdir pokemons && cd pokemons
$ touch page.tsx

TanStack Query の記事に書いた感じで設定して、pokemons/page.tsxHydrate を使ってサーバーコンポーネントでプリフェッチした内容をクライアントで使えるようにします。

// src/app/pokemons/page.tsx
export default async function Pokemons() {
  const queryClient = getQueryClient()
  await queryClient.prefetchQuery(['pokemons'], fetchPokemons)
  const dehydratedState = dehydrate(queryClient)

  return (
    <main className="p-[24px]">
      <Hydrate state={dehydratedState}>
        <PokemonsList />
      </Hydrate>
    </main>
  )
}

fetchPokemonsapi/index.ts に定義していて、このファイルには以下の内容が記載されています。

// src/app/api/index.ts
export type Pokemon = {
  name: string
  icon: string
  types: string[]
}

type Response = {
  count: number
  next: string
  previous: string
  results: Pokemon[]
}

export function fetchPokemonDetails(pokemons: []) {
  const promises = pokemons.map((pokemon: any) => {
    return fetch(pokemon.url, {
      headers: { 'Content-Type': 'application/json' },
    }).then((res) => {
      return res.json()
    }).catch((error) => {
      console.error(error)
    })
  })

  return Promise.all(promises)
}

export async function fetchPokemons(): Promise<Response> {
  const data =  await fetch('https://pokeapi.co/api/v2/pokemon', {
    headers: { 'Content-Type': 'application/json' },
  }).then((res) => {
    return res.json()
  }).catch((error) => {
    console.error(error)
  })

  const details = await fetchPokemonDetails(data.results)

  const res = {
    count: data.count,
    next: data.next,
    previous: data.previous,
    results: details.map((detail: any) => {
      return {
        name: detail.name,
        icon: detail.sprites.front_default,
        types: detail.types.map((type: any) => type.type.name)
      }
    })
  }

  return res
}

PokeAPI では https://pokeapi.co/api/v2/pokemon からは名前と詳細取得用の API URL くらいしか取得できないので、それぞれのさらに詳しいデータを fetchPokemonDetails で取得しています。 そしたら、ここで取得したデータが PokemonsList クライアントコンポーネント(pokemons/page.tsx で使ってるコンポーネント)へ渡ります。 PokemonsListcomponents/pokemons/index.tsx に作成します。

全容は以下の通りです。

// src/app/components/pokemons/index.tsx
'use client'

import { useEffect, useState } from 'react'
import Image from 'next/image'
import { fetchPokemons, fetchMorePokemons, Pokemon } from '@/app/api'
import { useQuery } from '@tanstack/react-query'
import InfiniteScroll from 'react-infinite-scroller'
import { RotatingLines } from  'react-loader-spinner'

export function PokemonsList() {
  const { data } = useQuery({ queryKey: ['pokemons'], queryFn: fetchPokemons })
  const [pokemons, setPokemons] = useState<Pokemon[]>([])
  const [nextPage, setNextPage] = useState<string>()

  const refetch = async () => {
    if (!nextPage) return

    const res = await fetchMorePokemons(nextPage)

    if (res.results.length === 0) return

    setPokemons([
      ...pokemons,
      ...res.results
    ])
    setNextPage(res.next)
  }

  useEffect(() => {
    if (data) {
      setPokemons(data.results)
      setNextPage(data.next)
    }
  }, [data])

  return (
    <section className="flex justify-center">
      <InfiniteScroll
        pageStart={0}
        loadMore={refetch}
        initialLoad={false}
        hasMore={true || false}
        loader={
          (
            <div className="w-full flex justify-center py-[100px]" key={0}>
              <RotatingLines
                strokeColor="grey"
                strokeWidth="5"
                animationDuration="0.75"
                width="96"
                visible={true}
              />
            </div>
          )
        }
      >
        <section className="max-w-[900px] flex flex-wrap">
          {
            pokemons.map((pokemon: Pokemon) => (
              <dl key={pokemon.name} className="w-[300px] flex justify-center flex-wrap py-[24px] relative">
                {
                  pokemon.icon && (
                    <Image
                      src={pokemon.icon}
                      alt={pokemon.name}
                      width={240}
                      height={240}
                      priority
                    />
                  )
                }
                <dt className="w-full text-center py-[10px]">
                  <p className="text-xl font-bold">
                    {pokemon.name.toUpperCase()}
                  </p>
                </dt>
              </dl>
            ))
          }
        </section>
      </InfiniteScroll>
    </section>
  )
}

useQuery でプリフェッチしたデータを受けて、まず useStatepokemons に格納されます(useEffect で初回表示時に格納されます)。 で、それらは map で一覧表示されるわけですが、これを InfiniteScroll で囲って、一番下まで到達したタイミングで loadMore に定義した処理(refetch メソッド)を実行させることで次のデータを取得しています。

次のデータを取得できる URL は PokeAPI のレスポンスに含まれているので、それを nextPage に記録してページの最下部に到達した時に利用しています。 refetch メソッドで const res = await fetchMorePokemons(nextPage) しているのが次のデータ取得を行なっている部分です。

fetchMorePokemonsapi/index.ts に定義されています。

// src/app/api/index.ts
export async function fetchMorePokemons(url: string): Promise<Response> {
  const data =  await fetch(url, {
    headers: { 'Content-Type': 'application/json' },
  }).then((res) => {
    return res.json()
  }).catch((error) => {
    console.error(error)
  })

  const details = await fetchPokemonDetails(data.results)

  const res = {
    count: data.count,
    next: data.next,
    previous: data.previous,
    results: details.map((detail: any) => {
      return {
        name: detail.name,
        icon: detail.sprites.front_default,
        types: detail.types.map((type: any) => type.type.name)
      }
    })
  }

  return res
}

基本的には fetchPokemons と同じですが、引数を取る点が異なります。 こいつで取得したデータはクライアントコンポーネントでポケモンのリストを溜め込んでいる pokemons へ追加され、画面上のポケモンも更新されるようになります。 また、次のデータの URL も更新して次の読み込みで使えるようにします。

これで無限ローディングの完成です!

まあ、ちょっとサーバー側で API の呼び出しに時間がかかるような構成にはなっていますが(api/index.ts あたりで画面表示に必要な要素をこねくり回しているところ)、とりあえずこんなところで良いでしょう。 実際にサービスを作る場合はこの辺クライアントで取り扱う方法も考慮した上で API 設計をしないといけないでしょうね。

を仕舞い

無限ローディングを実装したページを CloudFlare で公開しておきました。 こっちはポケモンのタイプ表示とか詳細画面とかお気に入り機能(Recoil 使いました)も追加してます。 一応、ローカルストレージとかも使ってないのでリロードしたら全部消えます。 https://pokedex-infinite-scroll.pages.dev/

ソースコードも置いておきます。 yutaro1204 / flutter_list_view_loading_demo