Пошук уроків, статей та іншого контенту
Побудуєте завантаження наступних порцій даних під час прокручування списку без дублювання елементів.
Infinite scroll — це спосіб завантаження списку частинами:
застосунок завантажує першу порцію даних;
користувач прокручує список;
коли він наближається до кінця, завантажується наступна порція;
нові елементи додаються до вже завантажених;
процес триває, доки сервер не повідомить, що дані закінчилися.
На відміну від звичайної пагінації, користувач не переходить між сторінками вручну.
Для визначення моменту, коли потрібно завантажити нові дані, зручно використовувати IntersectionObserver. Він повідомляє, коли спеціальний елемент-індикатор стає видимим у viewport.
Компонент infinite scroll зазвичай має:
масив уже завантажених елементів;
номер або курсор наступної порції;
стан завантаження;
прапорець hasMore;
елемент-індикатор у кінці списку;
IntersectionObserver, який стежить за індикатором.
Важливо не запускати кілька однакових запитів одночасно. Для цього можна використовувати useRef, який зберігатиме актуальний стан завантаження між рендерами.
Нижче наведено повний приклад. Замість справжнього сервера використовується функція fetchItems, яка імітує API та повертає дані сторінками.
import { useCallback, useEffect, useRef, useState } from "react";
const TOTAL_ITEMS = 47;
const PAGE_SIZE = 10;
function fetchItems(page) {
return new Promise((resolve) => {
setTimeout(() => {
const start = (page - 1) * PAGE_SIZE;
const end = Math.min(start + PAGE_SIZE, TOTAL_ITEMS);
const items = Array.from({ length: end - start }, (_, index) => {
const id = start + index + 1;
return {
id,
title: `Елемент ${id}`,
};
});
resolve({
items,
hasMore: end < TOTAL_ITEMS,
});
}, 700);
});
}
export default function InfiniteList() {
const [items, setItems] = useState([]);
const [hasMore, setHasMore] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const sentinelRef = useRef(null);
const loadingRef = useRef(false);
const pageRef = useRef(0);
const loadNextPage = useCallback(async () => {
if (loadingRef.current || !hasMore) {
return;
}
loadingRef.current = true;
setIsLoading(true);
setError(null);
const nextPage = pageRef.current + 1;
try {
const response = await fetchItems(nextPage);
setItems((currentItems) => {
const knownIds = new Set(currentItems.map((item) => item.id));
const newItems = response.items.filter(
(item) => !knownIds.has(item.id)
);
return [...currentItems, ...newItems];
});
pageRef.current = nextPage;
setHasMore(response.hasMore);
} catch {
setError("Не вдалося завантажити дані.");
} finally {
loadingRef.current = false;
setIsLoading(false);
}
}, [hasMore]);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
const [entry] = entries;
if (entry.isIntersecting) {
loadNextPage();
}
},
{
rootMargin: "200px",
}
);
observer.observe(sentinel);
return () => {
observer.disconnect();
};
}, [loadNextPage]);
return (
<main style={{ maxWidth: 600, margin: "0 auto", padding: 24 }}>
<h1>Список елементів</h1>
<ul style={{ padding: 0, listStyle: "none" }}>
{items.map((item) => (
<li
key={item.id}
style={{
padding: 16,
marginBottom: 8,
border: "1px solid #ddd",
borderRadius: 8,
}}
>
{item.title}
</li>
))}
</ul>
<div ref={sentinelRef} style={{ minHeight: 1 }} />
{isLoading && <p>Завантаження...</p>}
{error && (
<div>
<p>{error}</p>
<button onClick={loadNextPage}>Повторити</button>
</div>
)}
{!hasMore && !isLoading && <p>Усі елементи завантажено.</p>}
</main>
);
}Функція fetchItems(page) отримує номер сторінки та повертає:
{
items: [...],
hasMore: true
}У реальному застосунку замість неї буде HTTP-запит:
async function fetchItems(page) {
const response = await fetch(`/api/items?page=${page}`);
if (!response.ok) {
throw new Error("Помилка сервера");
}
return response.json();
}Важливо, щоб API також повертало ознаку наявності наступної порції. Це може бути hasMore, nextPage або курсор.
const pageRef = useRef(0);Після успішного завантаження номер сторінки збільшується:
const nextPage = pageRef.current + 1;
pageRef.current = nextPage;Номер сторінки оновлюється лише після успішної відповіді. Якщо запит завершився помилкою, наступна спроба повторить той самий запит.
IntersectionObserver може викликати callback кілька разів, поки елемент залишається видимим. Тому потрібна перевірка:
if (loadingRef.current || !hasMore) {
return;
}loadingRef.current не викликає повторний рендер компонента, але дозволяє миттєво перевірити, чи вже виконується запит.
Після завершення запиту значення скидається:
finally {
loadingRef.current = false;
setIsLoading(false);
}Блок finally виконується і після успішного запиту, і після помилки.
Навіть якщо клієнт завантажує сторінки послідовно, дублікати можуть виникнути через:
повторну ініціалізацію запиту;
особливості пагінації на сервері;
зміну даних між запитами;
повторне отримання частини попередньої сторінки.
У прикладі кожен елемент має унікальний id. Перед додаванням нової порції створюється набір уже відомих ідентифікаторів:
const knownIds = new Set(currentItems.map((item) => item.id));Після цього залишаються лише нові елементи:
const newItems = response.items.filter(
(item) => !knownIds.has(item.id)
);І тільки вони додаються до стану:
return [...currentItems, ...newItems];Для React також потрібно використовувати стабільний key:
<li key={item.id}>
{item.title}
</li>Не варто використовувати індекс масиву як key, якщо список може доповнюватися або змінюватися.
IntersectionObserverСпочатку внизу списку розміщується невеликий елемент:
<div ref={sentinelRef} />Потім observer починає стежити за ним:
observer.observe(sentinel);Коли індикатор потрапляє у viewport, викликається loadNextPage:
if (entry.isIntersecting) {
loadNextPage();
}Параметр rootMargin дозволяє почати завантаження трохи раніше:
{
rootMargin: "200px"
}У цьому випадку наступна порція почне завантажуватися, коли до кінця списку залишиться приблизно 200 пікселів. Завдяки цьому користувач не обов’язково побачить паузу між порціями.
IntersectionObserver потрібно відключати, коли компонент видаляється або коли змінюється callback:
return () => {
observer.disconnect();
};Без очищення можна отримати:
кілька активних observer для одного елемента;
повторні запити;
витоки пам’яті;
звернення до вже неактуального компонента.
Сервер має повідомити, що наступних даних більше немає. У прикладі це робить поле hasMore:
setHasMore(response.hasMore);Після цього loadNextPage більше не запускає запити:
if (loadingRef.current || !hasMore) {
return;
}Користувачу можна показати відповідне повідомлення:
{!hasMore && !isLoading && <p>Усі елементи завантажено.</p>}Це важливо, оскільки сам sentinel може залишатися видимим, особливо якщо список короткий.
Якщо не перевіряти isLoading або окремий loadingRef, кілька спрацьовувань observer можуть завантажити одну сторінку кілька разів.
Номер наступної сторінки слід фіксувати, але оновлювати основний номер лише після успішного завершення запиту. Інакше після помилки можна пропустити сторінку.
hasMoreБез цієї перевірки компонент продовжуватиме робити запити після завершення даних.
Операція:
setItems((currentItems) => [...currentItems, ...response.items]);не перевіряє дублікати. Для даних, які можуть перетинатися між сторінками, потрібно порівнювати унікальні ідентифікатори.
keyТакий код небажаний:
items.map((item, index) => (
<li key={index}>{item.title}</li>
))Краще використовувати ідентифікатор самого елемента:
items.map((item) => (
<li key={item.id}>{item.title}</li>
))Observer потрібно відключати в cleanup-функції useEffect, щоб старі підписки не продовжували працювати.
Infinite scroll завантажує дані порціями під час прокручування.
IntersectionObserver зручний для визначення наближення до кінця списку.
Прапорець завантаження запобігає паралельним запитам.
hasMore зупиняє завантаження після завершення даних.
Унікальні id і фільтрація через Set запобігають дублюванню.
Номер сторінки потрібно оновлювати лише після успішного запиту.
Observer потрібно очищати під час розмонтування компонента.