🪩
Remixで無限スクロールするコンポーネント
各バージョン
$ 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
(交差オブザーバー)を使います。
交差オブザーバー 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
にはidle
、submitting
、loading
があります。詳しいことは公式ドキュメントに書いてあります。
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>
)
}
参考
Discussion