🪩

Remixで無限スクロールするコンポーネント

2024/03/11に公開

各バージョン

$ node -v
v21.6.0
package.json
{
  // ...
  "dependencies": {
    "@remix-run/node": "^2.8.1",
    "@remix-run/react": "^2.8.1",
    "react": "^18.2.0",
  },
  "devDependencies": {
    "typescript": "^5.3.3",
  },
  // ...
}

無限スクロールするページ

まずはページに直接実装してみましょう。
ページネーションはカーソルページネーションで実装します。

データを取得・表示

無限スクロールの前に、取得部分を実装しておきます。「続きを取得する」ボタンをクリックすると、続きの要素が追加されます。
続きの要素を取得する際は、Remixで用意されているuseFetcherフックを使用しています(URLを変更する場合はForm、変更しない場合はfetcherといった使い分けのようです)

app/routes/posts.tsx
import { LoaderFunctionArgs, json } from '@remix-run/node'
import { useFetcher, useLoaderData } from '@remix-run/react'
import { FC, useEffect, useState } from 'react'

// 100件の記事データを生成
const data = [...Array(100)].map((_, i) => ({
  id: 'id' + i,
  title: 'タイトル' + i,
  body: '本文' + i,
}))

// postsを20件ずつ取得する
export const loader = async ({ request }: LoaderFunctionArgs) => {
  const url = new URL(request.url)
  const take = 20
  const cursor = url.searchParams.get('cursor') || null

  const cursorIndex = data.findIndex((p) => p.id === cursor)
  const skip = cursorIndex === -1 ? 0 : cursorIndex + 1

  const posts = data.slice(skip, take + skip)
  const endCursor = posts.at(-1)?.id ?? null
  const hasNextPage = data.length > take + skip

  return json({ posts, endCursor, hasNextPage })
}

const Posts: FC = () => {
  const {
    posts: initPosts,
    hasNextPage: initHasNextPage,
    endCursor: initCursor,
  } = useLoaderData<typeof loader>()

  const [posts, setPosts] = useState(initPosts)
  const [hasNextPage, setHasNextPage] = useState(initHasNextPage)
  const [cursor, setCursor] = useState(initCursor)

  const fetcher = useFetcher<typeof loader>()

  useEffect(() => {
    // 取得が完了したらpostsを更新する
    const fetchedData = fetcher.data
    if (fetchedData && fetchedData.posts.length > 0) {
      setPosts((prev) => [...prev, ...fetchedData.posts])
      setCursor(fetchedData.endCursor)
      setHasNextPage(fetchedData.hasNextPage)
    }
  }, [fetcher.data])

  return (
    <>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <h1>{post.title}</h1>
            <p>{post.body}</p>
          </li>
        ))}
      </ul>

      {hasNextPage && (
        <fetcher.Form method="get">
          <button name="cursor" value={cursor ?? ''}>
            続きを取得する
          </button>
        </fetcher.Form>
      )}
    </>
  )
}

export default Posts

無限スクロール

読み込み部分を無限スクロールにしていきます。

無限スクロールには、IntersectionObserver(交差オブザーバー)を使います。
https://developer.mozilla.org/ja/docs/Web/API/Intersection_Observer_API

交差オブザーバー API (Intersection Observer API) は、ターゲットとなる要素が、祖先要素または文書の最上位のビューポートと交差する変化を非同期的に監視する方法を提供します。

この文章だけではよくわかりませんが、今回は要素が画面に表示されているかどうかを監視するために使います。

app/routes/posts.tsx
import { LoaderFunctionArgs, json } from '@remix-run/node'
import { useFetcher, useLoaderData } from '@remix-run/react'
- import { FC, useEffect, useState } from 'react'
+ import { FC, useEffect, useRef, useState } from 'react'

// 100件の記事データを生成
const data = [...Array(100)].map((_, i) => ({
  id: 'id' + i,
  title: 'タイトル' + i,
  body: '本文' + i,
}))

// postsを20件ずつ取得する
export const loader = async ({ request }: LoaderFunctionArgs) => {
  const url = new URL(request.url)
  const take = 20
  const cursor = url.searchParams.get('cursor') || null

  const cursorIndex = data.findIndex((p) => p.id === cursor)
  const skip = cursorIndex === -1 ? 0 : cursorIndex + 1

  const posts = data.slice(skip, take + skip)
  const endCursor = posts.at(-1)?.id ?? null
  const hasNextPage = data.length > take + skip

  return json({ posts, endCursor, hasNextPage })
}

const Posts: FC = () => {
  const {
    posts: initPosts,
    hasNextPage: initHasNextPage,
    endCursor: initCursor,
  } = useLoaderData<typeof loader>()

  const [posts, setPosts] = useState(initPosts)
  const [hasNextPage, setHasNextPage] = useState(initHasNextPage)
  const [cursor, setCursor] = useState(initCursor)

  const fetcher = useFetcher<typeof loader>()

  useEffect(() => {
    // 取得が完了したらpostsを更新する
    const fetchedData = fetcher.data
    if (fetchedData && fetchedData.posts.length > 0) {
      setPosts((prev) => [...prev, ...fetchedData.posts])
      setCursor(fetchedData.endCursor)
      setHasNextPage(fetchedData.hasNextPage)
    }
  }, [fetcher.data])

+ const loadingRef = useRef<HTMLDivElement>(null)
+ useEffect(() => {
+   const loadingElement = loadingRef.current
+ 
+   const observer = new IntersectionObserver(([entry]) => {
+     if (entry?.isIntersecting && fetcher.state === 'idle') {
+       // 「読み込み中...」が表示された際に続きを取得するイベントを登録
+       fetcher.submit({ cursor: cursor ?? '' })
+     }
+   })
+ 
+   if (loadingElement) {
+     observer.observe(loadingElement)
+   }
+ 
+   return () => {
+     if (loadingElement) {
+       observer.unobserve(loadingElement)
+     }
+   }
+ }, [cursor, fetcher])

  return (
    <>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <h1>{post.title}</h1>
            <p>{post.body}</p>
          </li>
        ))}
      </ul>

      {hasNextPage && (
-       <fetcher.Form method="get">
-         <button name="cursor" value={cursor ?? ''}>
-           続きを取得する
-         </button>
-       </fetcher.Form>
+       <div ref={loadingRef}>読み込み中...</div>
      )}
    </>
  )
}

export default Posts

補足:fetcher.state === 'idle'

データ取得の重複を防ぐため、fetcher.state === 'idle'を使って取得できる状態かどうかを確認しています。
fetcher.stateにはidlesubmittingloadingがあります。詳しいことは公式ドキュメントに書いてあります。

const observer = new IntersectionObserver(([entry]) => {
  if (entry?.isIntersecting && fetcher.state === 'idle') {
    const formData = new FormData()
    formData.append('cursor', cursor ?? '')
    fetcher.submit(formData)
  }
})

コンポーネント化

ページでの実装ができたので、コンポーネントに切り出してみましょう。

app/components/infinite-scroll.tsx
import { FC, ReactNode, useEffect, useRef } from 'react'

const InfiniteScroll: FC<
  Readonly<{
    children: ReactNode
    loadMore: () => void
    hasNextPage: boolean
  }>
> = ({ children, loadMore, hasNextPage }) => {
  const loadingRef = useRef<HTMLDivElement>(null)
  useEffect(() => {
    const loadingElement = loadingRef.current

    const observer = new IntersectionObserver(([entry]) => {
      if (entry?.isIntersecting) {
        loadMore()
      }
    })

    if (loadingElement) {
      observer.observe(loadingElement)
    }

    return () => {
      if (loadingElement) {
        observer.unobserve(loadingElement)
      }
    }
  }, [loadMore])
  return (
    <>
      <ul>{children}</ul>
      {hasNextPage && <div ref={loadingRef}>読み込み中...</div>}
    </>
  )
}

export default InfiniteScroll

IntersectionObserverで続きを取得する部分を切り出しました。
呼び出し側はこんな感じです。

const Posts: FC = () => {
  const {
    posts: initPosts,
    hasNextPage: initHasNextPage,
    endCursor: initCursor,
  } = useLoaderData<typeof loader>()

  const [posts, setPosts] = useState(initPosts)
  const [hasNextPage, setHasNextPage] = useState(initHasNextPage)
  const [cursor, setCursor] = useState(initCursor)

  const fetcher = useFetcher<typeof loader>()

  useEffect(() => {
    const fetchedData = fetcher.data
    if (fetchedData && fetchedData.posts.length > 0) {
      setPosts((prev) => [...prev, ...fetchedData.posts])
      setCursor(fetchedData.endCursor)
      setHasNextPage(fetchedData.hasNextPage)
    }
  }, [fetcher.data])

  return (
    <InfiniteScroll
      loadMore={() => {
        if (fetcher.state === 'idle') {
          fetcher.submit({ cursor: cursor ?? '' })
        }
      }}
      hasNextPage={hasNextPage}
    >
      {posts.map((post) => (
        <li key={post.id}>
          <h1>{post.title}</h1>
          <p>{post.body}</p>
        </li>
      ))}
    </InfiniteScroll>
  )
}

参考

https://remix.run/docs/en/main/hooks/use-fetcher
https://remix.run/docs/en/main/discussion/form-vs-fetcher
https://developer.mozilla.org/ja/docs/Web/API/Intersection_Observer_API
https://dev.to/vetswhocode/infinite-scroll-with-remix-run-1g7

Discussion