Next.js v13 でも無限ローディングやる、React Server Components 使います
CREATED: 2023 / 08 / 06 Sun
UPDATED: 2023 / 08 / 06 Sun
とりあえず無限ローディング
プロジェクト作る。
$ 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.tsx
で Hydrate
を使ってサーバーコンポーネントでプリフェッチした内容をクライアントで使えるようにします。
// 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>
)
}
fetchPokemons
は api/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
で使ってるコンポーネント)へ渡ります。
PokemonsList
は components/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
でプリフェッチしたデータを受けて、まず useState
で pokemons
に格納されます(useEffect
で初回表示時に格納されます)。
で、それらは map
で一覧表示されるわけですが、これを InfiniteScroll
で囲って、一番下まで到達したタイミングで loadMore
に定義した処理(refetch
メソッド)を実行させることで次のデータを取得しています。
次のデータを取得できる URL は PokeAPI のレスポンスに含まれているので、それを nextPage
に記録してページの最下部に到達した時に利用しています。
refetch
メソッドで const res = await fetchMorePokemons(nextPage)
しているのが次のデータ取得を行なっている部分です。
fetchMorePokemons
も api/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