SWRでスクロールを検知する、無限スクロールの実装

2023/03/17に公開

はじめに

私は自分のWebサービスにページネーション機能を付けようと思っていました。そこで、せっかくなら無限スクロールにしたいと思いSWRのuseSWRInfiniteを使うと、思ったより簡単に実装出来たので備忘録として記事にしました。

SWRとは

SWRは、Vercel社が開発したReact向けのデータフェッチングライブラリです。SWRは、アプリケーションのパフォーマンスを向上させ、データの取得と更新を簡単に行えるように設計されています。

ローカルキャッシュとリモートサーバーの間でデータを自動的に管理し、適切なタイミングでキャッシュを更新します。また、オプションを設定することで、キャッシュの有効期限や再取得のタイミングを調整することができます。

SWRは、Reactアプリケーションでデータフェッチングを簡単に実装するための強力なツールであり、特にVercelを利用したNext.jsアプリケーションでよく使われています。

“SWR” という名前は、 HTTP RFC 5861(opens in a new tab) で提唱された HTTP キャッシュ無効化戦略である stale-while-revalidate に由来しています。 SWR は、まずキャッシュからデータを返し(stale)、次にフェッチリクエストを送り(revalidate)、最後に最新のデータを持ってくるという戦略です。

https://swr.vercel.app/ja
こちらの記事は、かなりオススメです。
https://zenn.dev/uttk/articles/b3bcbedbc1fd00

useSWRInfinite

SWRが提供しているカスタムフックにuseSWRInfiniteがあります。useSWRInfiniteは1つのフックスで多数のリクエストを送ることが出来ます。今回はこのuseSWRInfiniteを利用して無限スクロールを実装します。
https://swr.vercel.app/ja/docs/pagination.ja

準備

APIの用意

今回は、エンドポイントとしてGETメソッドで投稿一覧を返す/postsを用意し、クエリパラメータで以下の二つを受け取るページネーションを実装しました。

  1. page:  ページ毎に表示するデータを返す
  2. per:  1ページ当たりのデータ数

以下のようなURLをリクエストする形になります。
https://example/api/posts?page=1&per=10

レスポンス例

リクエスト例1
https://example/api/posts?page=1&per=10

レスポンス例1
[
  {
    "id": 1,
    "name": "John",
    "comment": "Hello World!"
  },
  {
    "id": 2,
    "name": "Jane",
    "comment": "Nice to meet you"
  },
  ...
  {
    "id": 10,
    "name": "Alice",
    "comment": "How are you?"
  }
]

リクエスト例2
https://example/api/posts?page=2&per=10

レスポンス例2
[
  {
    "id": 11,
    "name": "David",
    "comment": "I like programming"
  },
  {
    "id": 12,
    "name": "Emily",
    "comment": "Today is a sunny day"
  },
  ...
  {
    "id": 20,
    "name": "Tom",
    "comment": "Nice weather today"
  }
]

useSWRInfiniteの使い方

基本的な構造はuseSWRと同じ形です。

import useSWRInfinite from 'swr/infinite'
import { Post } from 'types/post' // 受け取るデータの型

const getKey = (pageIndex: number, previousPageData: Post[][]) => {
  if (previousPageData && !previousPageData.length) return null // 最後に到達した
  return `/posts?page=${pageIndex + 1}` // SWR キー
}

const fetcher = useCallback(
  async (url: string) => await fetchApi<Post[]>(url, 'GET'),
  [],
)

const { data, size, setSize } = useSWRInfinite(getKey, fetcher)

useSWRInfiniteの引数

useSWRInfiniteの第一引数にgetKey、第二引数にfetcher関数を渡し、それ以外は第三引数にoptionの引数を渡します。

  • getKey

    • 役割
      • URLを生成する関数。ここで生成した値をfetcherに渡す。
      • 引数でpageIndex現在のページ番号、previousPageData前回のページで取得したデータを受け取る
        • pageIndexの初期値は0なので、使うときは+1する
    • 処理
      • クエリパラメータのpageを1ずつ増やしていく
      • 前回のページで取得したデータが空の時に、nullを返しデータの取得を終了
  • fetcher

    • 役割
      • getKeyから受け取ったkeyをもとにデータを取得
    • 処理
      • ここでは独自関数のfetchApiを使っており、このような処理を行っています。
      fetcher
        const BASE_URL = "https://example/api" // "/posts"より前のURL
        
        const fetcher = useCallback(async (url: string) => {
          let res: { data: Post[] } | undefined
          try {
            // `getKey`から受け取るURLは`/posts?page=1`のような形だから、そのまま使わず`https://example/api/posts?page=1`になるように加工する
            const result = await fetch(`${BASE_URL}/${url}`)
            if (!result.ok) throw new Error()
            res = await result.json()
            if (!res) throw new Error()
          } catch (e) {
            console.error(e)
          }
      
          return res ? res.data : (res as unknown as Post[])
        }, [])
      

useSWRInfiniteはgetKey, fetcher以外の引数も受け取ることが出来ます。

その他の引数一覧
  const { data, size, setSize } = useSWRInfinite(getKey, fetcher, {
    initialSize: 1,
    revalidateAll: false,
    revalidateFirstPage: true,
    persistSize: false,
    revalidateOnReconnect: false,
    revalidateIfStale: false,
    revalidateOnFocus: false,
    ...
  })

  • options: useSWR がサポートしているすべてのオプションに加えて、useSWRInfinite独自の追加オプションを受け取ります。
    • initialSize = 1: 1ページ目でロードするページ数
    • revalidateAll = false: 常にすべてのページに対して再検証を試みる
    • revalidateFirstPage = true: 常に最初のページを再検証します
    • persistSize = false: 最初のページのキーが変更されたときに、ページサイズを 1 (またはセットされていれば initialSize)にリセットしない
    • parallel = false: fetches multiple pages in parallel

optionsでは、useSWRと同じものも受け取れます
https://swr.vercel.app/ja/docs/api#オプション

オススメのオプション

私はリクエスト回数を減らしたいと考えているので、デフォルトの設定で再検証をするものをオフにしています。

useSWRInfinite(
    getKey,
    fetcher,
    {
      revalidateIfStale: false, // キャッシュがあっても再検証しない
      revalidateOnFocus: false, // windowをフォーカスすると再検証しない
      revalidateFirstPage: false, // 2ページ目以降を読み込むとき毎回1ページ目を再検証しない
    },
  )

useSWRInfiniteの返り値

useSWRInfiniteの返り値は大体useSWRと同じですが、dataが少し変わり、size, setSizeが追加されています。

  • data
    • 各ページのフェッチしたレスポンス値の配列。
    • つまり返ってくるデータは、Post[]ではなく、Post[][]となるので注意が必要。
    [
      [Post1, Post2, ...], // 1ページ目で取得したPost[]
      [Post11, Post12, ...], // 2ページ目で取得したPost[]
    ]
    
  • error
    • fetcher によって投げられたエラー (もしくは undefined)
  • isLoading
    • 実行中のリクエストがあり "ロードされたデータ" がない状態。
    • ここでは1ページ目のデータを取得中ならtrue。2ページ目以降は既にデータが存在しているのでfalseのまま変わらない。
  • isValidating
    • リクエストまたは再検証の読み込みがある状態
    • データを取得中だとtrue。2ページ目以降のデータも取得中ならtrueになる。
  • mutate
    • useSWR のバインドされたミューテート関数と同じで、データ配列を操作する
  • size
    • 現在取得したデータのページ番号
  • setSize
    • 次に取得するページ数を引数で渡して実行する

ボタン押したら次のページを読み込む処理

それではボタンを押したら次のページを読み込む処理を追加していきます。

import useSWRInfinite from 'swr/infinite'
import { Post } from 'types/post' // 受け取るデータの型

export default function App () {
  const getKey = (pageIndex: number, previousPageData: Post[][]) => {
    if (previousPageData && !previousPageData.length) return null // 最後に到達した
    return `/posts?page=${pageIndex + 1}?per=12` // SWR キー
  }

  const fetcher = useCallback(
    async (url: string) => await fetchApi<Post[]>(url, 'GET'),
    [],
  )

  const { data, size, setSize } = useSWRInfinite(getKey, fetcher, {
    revalidateIfStale: false, // キャッシュがあっても再検証
    revalidateOnFocus: false, // windowをフォーカスすると再検証
    revalidateFirstPage: false, // 2ページ目以降を読み込むとき毎回1ページ目を再検証
  })
  
  /*
    ページが最後に到達したかをisReachingEndで定義
  */
  const limit = 12 // 1ページあたり表示数
  const isEmpty = data?.[0]?.length === 0 // 1ページ目のデータが空
  const isReachingEnd = isEmpty || (data && data?.[data?.length - 1]?.length < limit) // 1ページ目のデータが空 or データの最後のデータが1ページあたりの表示数より少ないない

return (
  <>
    {data && (
      <div>
        <div className='flex flex-wrap gap-6 justify-center'>
	  {/* dataの型がPost[][]なので、flatでPost[]に変換する */}
          {data.flat().map((post) => (
            <div key={post.id}>
              <PostItem post={post} />
            </div>
          ))}
        </div>

        {/* 最後のページに到達していなければ、もっと読み込むを表示する */}
        {!isReachingEnd && (
	  <Button
            onClick={() => {
              setSize(size + 1)
            }}
          >
            もっと読み込む
          </Button>
        )}
      </div>
    )}
  </>
)

setSizeで次のページのデータを読み込むことが出来るので、次のページ番号であるsize + 1setSizeに渡すことで次のページを取得することが出来ます。

<Button
  onClick={() => {
    setSize(size + 1)
  }}
>
  もっと読み込む
</Button>

ですが、最後のページに到達した時にsetSize(size + 1)を実行しても、データを読み込むことは出来ません。なのでisReachingEndで最後のページかを確認して、最後のページならもっと読み込むを表示しないようにしています。

  const limit = 12 // 1ページあたり表示数
  const isEmpty = data?.[0]?.length === 0 // 1ページ目のデータが空
  const isReachingEnd = isEmpty || (data && data?.[data?.length - 1]?.length < limit) // 1ページ目のデータが空 or データの最後のデータが1ページあたりの表示数より少ないない

動作は以下のようになります。最後のページに到達するともっと読み込むが表示されないことも確認できます。

無限スクロールの実装

次は、スクロールを検知して自動で次のページを読み込む処理を実装します。いわゆる無限スクロールですね。

ここでは、実装を楽にするためにライブラリ(react-intersection-observer)を導入します。
https://www.npmjs.com/package/react-intersection-observer

$ npm i react-intersection-observer
or
$ yarn add react-intersection-observer
or
$ pnpm add react-intersection-observer

react-intersection-observerを組み込むと次のようなコードになります

import { useCallback } from 'react'
import { PostItem } from 'components/post/PostItem'
import { Loader } from 'components/shares/base/Loader'
import { Post } from 'types/post'
import { fetchApi } from 'utils/api'
import { useInView } from 'react-intersection-observer'
import useSWRInfinite from 'swr/infinite'

export default function App () {
  const getKey = (pageIndex: number, previousPageData: Post[][]) => {
    if (previousPageData && !previousPageData.length) return null // 最後に到達した
    return `/posts?page=${pageIndex + 1}&per=12` // SWR キー
  }

  const fetcher = useCallback(
    async (url: string) => await fetchApi<Post[]>(url, 'GET'),
    [],
  )

  const { data, size, setSize, isValidating } = useSWRInfinite(
    getKey,
    fetcher,
    {
      revalidateOnReconnect: false,
      revalidateIfStale: false,
      revalidateOnFocus: false,
      revalidateFirstPage: true,
    },
  )

  const limit = 12
  const isEmpty = data?.[0]?.length === 0
  const isReachingEnd =
    isEmpty || (data && data?.[data?.length - 1]?.length < limit)

  // 画面下の要素にrefを渡し、refが画面に表示されたらisScrollEndがtrueになる
  const { ref, inView: isScrollEnd } = useInView()

  // 画面が一番下 かつ データを取得中でない かつ ページが最後に到達していない時に、次のページを取得
  if (isScrollEnd && !isValidating && !isReachingEnd) {
    setSize(size + 1)
  }
  
  return (
    <>
      {data && (
        <div className='flex flex-wrap gap-6 justify-center'>
          {data.flat().map((post, i) => (
            <div key={i}>
              <PostItem post={post} />
            </div>
          ))}
        </div>
      )}

      {/* データ取得時は検知の要素を表示しない */}
      {!isValidating && <div ref={ref} aria-hidden='true' />}
      {/* データ取得時はローダーを表示する */}
      {isValidating && <Loader size='xl' />}
    </div>
  )
}

react-intersection-observerが提供しているuseInViewは、refを要素に渡し、その要素が画面に表示されると、inViewがtrueになるカスタムフックです。

なので、画面下に空のdivを追加してrefを渡しています。
ここで、isValidatingがfalseの時だけ表示している理由は、データを取得中の時もrefを表示すると、データの取得が終わってから、画面に追加されるまでの間にrefが検知されてしまい、結果最後のページまで読み込んでしまうからです。
なので、データの取得中の時はrefを表示しないようにしています。

  // 画面下の要素にrefを渡し、refが画面に表示されたらisScrollEndがtrueになる
  const { ref, inView: isScrollEnd } = useInView()

  ...
  return(
    ...
    {!isValidating && <div ref={ref} aria-hidden='true' />}

また次のページを読み込むタイミングは、画面が一番下 かつ データを取得中でない かつ ページが最後に到達していない時なので、次のようになります。

  // 画面が一番下 かつ データを取得中でない かつ ページが最後に到達していない時に、次のページを取得
  if (isScrollEnd && !isValidating && !isReachingEnd) {
    setSize(size + 1)
  }

動作は以下のようになります。今回は、画面を下にスクロールする度に次のページが読み込めていることが分かります。また最後のページに到達するとローダーが消えるのも確認できます。

使いやすいようにカスタムフック作成

これで無限スクロールが動作するようになりました。次はこの処理を他ページでも使いやすくするためにカスタムフックを作成します。

src/hooks/useGetInfinite.ts
// データ1つの型をDataで受け取る
export const useGetInfinite = <Data = any>(
  url: string, // /posts の部分だけ受け取る
  limit = 12, // 1ページあたりの表示データ数
) => {
  // データは1ページ毎に配列に入るので、Data[][]となる
  const getKey = useCallback(
    (pageIndex: number, previousPageData: Data[][]) => {
      if (previousPageData && !previousPageData.length) return null // 最後に到達した
      return `${url}?page=${pageIndex + 1}&per=${limit}` // SWR キー
    },
    [limit, url],
  )

  // 取得するのは1ページ毎のデータの集まりなので、Data[]となる
  const fetcher = useCallback(
    async (url: string) => await fetchApi<Data[]>(url, 'GET'),
    [],
  )

  // useSWRInfiniteの返り値を全て使いたいので定義
  const SWRInfiniteResponse = useSWRInfinite<Data[]>(getKey, fetcher, {
    revalidateIfStale: false, // キャッシュがあっても再検証
    revalidateOnFocus: false, // windowをフォーカスすると再検証
    revalidateFirstPage: false, // ページを読み込むとき毎回1ページ目を再検証
  })

  // isReachingEndを出すために一度、分割代入で取り出す
  const { data, size, setSize } = SWRInfiniteResponse

  // 最後に到達した
  const isEmpty = data?.[0].length === 0
  const isReachingEnd =
    isEmpty || (data && data?.[data?.length - 1]?.length < limit)

  // もっと読み込む
  const fetchMore = useCallback(() => {
    setSize(size + 1)
  }, [setSize, size])

  return {
    ...SWRInfiniteResponse, // useSWRInfiniteの返り値を全部返す
    data: data?.flat(), // dataの型がData[][]となっているので、flatでData[]に変換する
    isReachingEnd,
    fetchMore,
  }
}

呼び出す側は、url("/posts")、limit(ここでは省略)、データの型(Post)を渡すだけにしました。

export const useGetInfinite = <Data = any>(
  url: string, // /posts の部分だけ受け取る
  limit = 12, // 1ページあたりの表示データ数。設定しないと12

返り値のdataだけは、そのままだとData[][]となり使いにくいので、.flatでData[]型に変換してから返すように変更しました。これにより呼び出す側は、data.map()と使うことが出来ます。

  return {
    ...SWRInfiniteResponse, // useSWRInfiniteの返り値を全部返す
    data: data?.flat(), // dataの型がData[][]となっているので、flatでData[]に変換する
    isReachingEnd,
    fetchMore,
  }

呼び出す側はこちらのようになります。
すっきりしました!

src/pages/index.ts
import { useInView } from 'react-intersection-observer'
import { useGetInfinite } from 'hooks/useApi'
import { Post } from 'types/post'

export default function App() {
  const {
    data: posts, // dataをpostsという名前で使う。型はPost[]
    isValidating, // データを取得中
    isReachingEnd, // 最後に到達したか
    fetchMore, // 次のページを読み込む
  } = useGetInfinite<Post>('/posts') // "https://example/api/posts"からPost[]型のデータを取得する

  // 画面下の要素にrefを渡し、refが画面に表示されたらisScrollEndがtrueになる
  const { ref, inView: isScrollEnd } = useInView()

  // 画面が一番下 かつ データを取得中でない かつ ページが最後に到達していない時に、次のページを取得
  if (isScrollEnd && !isValidating && !isReachingEnd) {
    fetchMore()
  }

  return (
    <>
      {posts && (
        <div className='flex flex-wrap gap-6 justify-center'>
          {/* posts.flatをしなくてOK カスタムフックを返すときにflatしている */}
          {posts.map((post, i) => (
            <PostItem key={i} post={post} />
          ))}
        </div>
      )}
      {/* データ取得時は検知の要素を表示しない */}
      {!isValidating && <div ref={ref} aria-hidden='true' />}
      {/* データ取得時はローダーを表示する */}
      {isValidating && <Loader size='xl' />}
    </>
  )
}

終わり

今回実装したリポジトリはこちらです。良ければ見ていってください!

https://github.com/akiogitgit/share-pos/blob/main/src/hooks/useApi.ts#L34-L84

https://github.com/akiogitgit/share-pos/blob/main/src/pages/index.tsx#L11-L52

Discussion