一覧をスクロールしていくと、末尾で次のデータが自動で読み込まれる「無限スクロール」は、Vue 3ではIntersection Observer APIで実装するのが定番です。スクロールイベントを自分で監視して位置を計算する必要がなく、コードが短くなります。ただし、コピーして動かすと止まってしまう落とし穴がいくつかあります。この記事では、その対策を入れた実装を、動くコードで解説します。
実装の流れは4つです。①リスト末尾に「監視用の要素」を置く、②Intersection Observerでその要素が画面に入ったかを検知する、③入ったら次のページを取得する、④取得し終わったら監視を止める(または何もしない)。
基本の実装:終了判定とエラー処理つき
無料で試せる jsonplaceholder のAPI(全100件、_pageと_limitでページ分割できます)を使った例です。
<script setup>
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
const LIMIT = 10
const posts = ref([])
const page = ref(1)
const isLoading = ref(false)
const hasMore = ref(true)
const error = ref('')
const trigger = ref(null)
let observer = null
async function loadMore() {
if (isLoading.value || !hasMore.value) return
isLoading.value = true
error.value = ''
try {
const res = await fetch(
`https://jsonplaceholder.typicode.com/posts?_limit=${LIMIT}&_page=${page.value}`
)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
posts.value.push(...data)
page.value++
// 1ページ分に満たなければ、これが最後
if (data.length < LIMIT) hasMore.value = false
} catch (e) {
error.value = '読み込みに失敗しました。'
} finally {
isLoading.value = false
}
// 読み込み後も監視要素が画面内に残っている場合に備えて、再判定させる
if (hasMore.value && !error.value && observer && trigger.value) {
await nextTick()
observer.unobserve(trigger.value)
observer.observe(trigger.value)
}
}
onMounted(() => {
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) loadMore()
},
{ rootMargin: '200px' } // 末尾の200px手前で先読みする
)
observer.observe(trigger.value)
})
onBeforeUnmount(() => {
observer?.disconnect()
})
</script>
<template>
<div class="post-list">
<div v-for="post in posts" :key="post.id" class="post-item">
{{ post.id }}. {{ post.title }}
</div>
<!-- 監視用の要素。常に末尾に置く -->
<div ref="trigger" class="load-trigger">
<p v-if="isLoading">読み込み中...</p>
<p v-else-if="error">
{{ error }}
<button type="button" @click="loadMore">再試行</button>
</p>
<p v-else-if="!hasMore">すべて読み込みました</p>
</div>
</div>
</template>
<style scoped>
.post-list { max-width: 600px; margin: 0 auto; }
.post-item { padding: 16px; border-bottom: 1px solid #ccc; }
.load-trigger { min-height: 40px; padding: 16px; text-align: center; color: #888; }
</style>
よくある落とし穴と、コードでの対策
読み込みのあと、次の取得が止まる
Intersection Observerのコールバックは、要素の交差状態が変わったときにだけ呼ばれます。取得したデータが少なくて、監視用の要素が画面内に残ったままだと、状態が変わらず、次の読み込みが始まりません。上のコードでは、読み込みのあとにunobserveとobserveをやり直して、再判定させています。
全件取得したあとも、リクエストが飛び続ける
終わりの判定がないと、データが空になっても、監視要素が見えるたびにリクエストを送ってしまいます。この例では、取得件数がLIMIT未満ならhasMoreをfalseにして、以後の読み込みを止めています。APIが総件数を返すなら、それと比べる方法でも構いません。
通信に失敗すると、読み込み中のまま固まる
旧来のコードでは、fetchが失敗するとisLoadingがtrueのまま戻らず、二度と読み込めなくなります。try/catch/finallyで必ず戻し、再試行ボタンを出しましょう。エラーの状態管理の考え方は非同期APIの状態管理パターンにまとめています。
thresholdを1.0にすると反応しないことがある
threshold: 1.0は、要素の全体が見えたときだけ反応します。ブラウザのズームや、要素の高さが小数になる環境では、発火しないことがあります。監視用の要素は小さくて構わないので、既定値のままにして、rootMarginで先読みさせるのが安定します。
後片付けはdisconnectで行う
コンポーネントが消えるタイミングでは、テンプレート参照がすでにnullになっていることがあり、unobserve(loadTrigger.value)が何もしないまま、監視が残る場合があります。disconnect()で、すべての監視をまとめて解除します。
rootMarginで先読みする
rootMargin: '200px'を指定すると、監視用の要素が画面の下端から200px手前に来た時点で、次の読み込みが始まります。ユーザーが末尾に着く前にデータが届くので、待たされる感じが減ります。値を大きくしすぎると、不要な先読みが増えます。
composableにして使い回す
複数の画面で使うなら、監視の処理をcomposableに切り出します。コンポーネントは「何を読み込むか」だけに集中できます。composableの考え方はComposable関数でロジックを共通化する方法で詳しく解説しています。
// useInfiniteScroll.js
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
export function useInfiniteScroll(callback, options = {}) {
const target = ref(null)
let observer = null
onMounted(() => {
observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) callback()
}, { rootMargin: '200px', ...options })
observer.observe(target.value)
})
onBeforeUnmount(() => observer?.disconnect())
// 読み込み後に呼ぶと、画面内に残っていても再判定される
async function recheck() {
if (!observer || !target.value) return
await nextTick()
observer.unobserve(target.value)
observer.observe(target.value)
}
return { target, recheck }
}
特定の要素の中でスクロールする場合
画面全体ではなく、高さを固定したボックスの中でスクロールさせる場合は、rootにそのスクロール要素を渡します。省略すると、ブラウザのビューポートが基準になります。
<!-- スクロール領域が window ではなく、特定の要素のとき -->
<div ref="scroller" class="scroller">
...
<div ref="trigger"></div>
</div>
<script setup>
// root に、スクロールする側の要素を渡す
observer = new IntersectionObserver(callback, {
root: scroller.value,
rootMargin: '200px'
})
</script>
無限スクロールを使うべきでない場面
- フッターに到達したい画面:読み込みのたびにフッターが押し下げられ、たどり着けません。
- 特定の項目に戻りたい一覧:ページ番号がないと、位置を共有したり、再訪問で同じ場所に戻ったりしにくくなります。その場合はページネーションが向いています。
- 検索エンジンに載せたい一覧:クライアントで追加読み込みする内容は、クローラーに届かないことがあります。
キーボード操作や支援技術を使う人のために、自動読み込みと並べて「もっと見る」ボタンを置いておくと、使いやすくなります。
関連記事
- 同じ仕組みで画像の読み込みを遅らせるなら画像の遅延読み込み(Lazy Loading)
- スクロールに合わせて要素を表示するならスクロールアニメーションの実装
- APIからのデータ取得の基本は非同期データの取得と表示
- リストの表示が重くなったらv-once・v-memoの使い方
よくある質問(FAQ)
unobserveとobserveをやり直してください。v-memoも有効です。まとめ
無限スクロールは、末尾に置いた監視用の要素をIntersection Observerで見張り、画面に入ったら次を読み込む、という仕組みです。実用にするには、読み込み後の再判定、終端の判定、失敗時の再試行、disconnect()による後片付けが欠かせません。あわせて、フッターに届かない、位置を共有しにくいといった弱点を踏まえ、ページネーションと使い分けてください。

