Пошук уроків, статей та іншого контенту
Реалізуєте розбиття великих наборів даних на сторінки та синхронізацію пагінації з параметрами URL.
Пагінація — це розбиття великого набору даних на окремі сторінки. Користувач бачить лише частину елементів, а не весь список одразу.
Наприклад, замість відображення 10 000 товарів можна показувати по 20:
сторінка 1 містить елементи з 1-го по 20-й;
сторінка 2 — з 21-го по 40-й;
сторінка 3 — з 41-го по 60-й.
Пагінація допомагає:
зменшити кількість елементів у DOM;
зробити інтерфейс зрозумілішим;
зменшити обсяг даних, які передаються від сервера;
швидше завантажувати великі списки.
Існує два поширені підходи:
Клієнтська пагінація — усі дані вже завантажені в браузер, а React показує лише потрібний фрагмент.
Серверна пагінація — сервер повертає лише дані для поточної сторінки.
Для невеликих наборів даних можна використовувати клієнтський підхід. Для великих наборів зазвичай застосовують серверну пагінацію.
Нехай:
totalItems — загальна кількість елементів;
pageSize — кількість елементів на сторінці;
currentPage — номер поточної сторінки.
Загальна кількість сторінок обчислюється так:
const totalPages = Math.ceil(totalItems / pageSize);Щоб отримати індекси елементів для поточної сторінки:
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;Після цього можна взяти потрібну частину масиву:
const visibleItems = items.slice(startIndex, endIndex);Метод slice не змінює початковий масив, а повертає його частину.
Номер сторінки зручно зберігати в параметрі URL:
/products?page=3&size=20Тут:
page=3 — третя сторінка;
size=20 — 20 елементів на сторінці.
Переваги такого підходу:
сторінку можна додати в закладки;
URL можна передати іншому користувачу;
після перезавантаження браузера відкривається та сама сторінка;
навігація браузера може відновлювати стан пагінації;
стан сторінки доступний безпосередньо з адресного рядка.
Для роботи з параметрами URL у браузері використовується URLSearchParams.
const params = new URLSearchParams(window.location.search);
const page = params.get('page');
const size = params.get('size');Значення, отримані з URL, завжди є рядками, тому перед використанням їх потрібно перетворити на числа та перевірити.
Нижче наведено повний приклад із такими можливостями:
відображення списку товарів;
перемикання сторінок;
вибір кількості елементів на сторінці;
збереження page і size в URL;
відновлення стану після перезавантаження;
обробка кнопок браузера «Назад» і «Вперед».
import { useEffect, useMemo, useState } from 'react';
const products = Array.from({ length: 87 }, (_, index) => ({
id: index + 1,
name: `Товар ${index + 1}`,
category: ['Електроніка', 'Книги', 'Одяг'][index % 3],
price: 500 + index * 125,
}));
const allowedPageSizes = [5, 10, 20];
function getPaginationFromUrl() {
const params = new URLSearchParams(window.location.search);
const requestedSize = Number(params.get('size'));
const pageSize = allowedPageSizes.includes(requestedSize)
? requestedSize
: 10;
const totalPages = Math.max(1, Math.ceil(products.length / pageSize));
const requestedPage = Number(params.get('page'));
const page = Number.isInteger(requestedPage)
? Math.min(Math.max(requestedPage, 1), totalPages)
: 1;
return {
page,
pageSize,
};
}
function App() {
const initialPagination = getPaginationFromUrl();
const [page, setPage] = useState(initialPagination.page);
const [pageSize, setPageSize] = useState(initialPagination.pageSize);
const totalPages = Math.max(1, Math.ceil(products.length / pageSize));
const visibleProducts = useMemo(() => {
const startIndex = (page - 1) * pageSize;
const endIndex = startIndex + pageSize;
return products.slice(startIndex, endIndex);
}, [page, pageSize]);
useEffect(() => {
function handlePopState() {
const nextPagination = getPaginationFromUrl();
setPage(nextPagination.page);
setPageSize(nextPagination.pageSize);
}
window.addEventListener('popstate', handlePopState);
return () => {
window.removeEventListener('popstate', handlePopState);
};
}, []);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
params.set('page', String(page));
params.set('size', String(pageSize));
const nextSearch = `?${params.toString()}`;
if (nextSearch !== window.location.search) {
window.history.replaceState(null, '', nextSearch);
}
}, [page, pageSize]);
useEffect(() => {
if (page > totalPages) {
setPage(totalPages);
}
}, [page, totalPages]);
function handlePageChange(nextPage) {
if (nextPage < 1 || nextPage > totalPages || nextPage === page) {
return;
}
setPage(nextPage);
}
function handlePageSizeChange(event) {
const nextPageSize = Number(event.target.value);
setPageSize(nextPageSize);
setPage(1);
}
return (
<main style={{ maxWidth: 700, margin: '40px auto', padding: 16 }}>
<h1>Каталог товарів</h1>
<label>
Товарів на сторінці:{' '}
<select value={pageSize} onChange={handlePageSizeChange}>
{allowedPageSizes.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</label>
<p>
Усього товарів: {products.length}. Поточна сторінка: {page} з{' '}
{totalPages}.
</p>
<ul>
{visibleProducts.map((product) => (
<li key={product.id} style={{ marginBottom: 12 }}>
<strong>{product.name}</strong>
<br />
Категорія: {product.category}
<br />
Ціна: {product.price} грн
</li>
))}
</ul>
<nav aria-label="Пагінація">
<button
type="button"
onClick={() => handlePageChange(page - 1)}
disabled={page === 1}
>
Попередня
</button>
{Array.from({ length: totalPages }, (_, index) => {
const pageNumber = index + 1;
return (
<button
key={pageNumber}
type="button"
onClick={() => handlePageChange(pageNumber)}
aria-current={pageNumber === page ? 'page' : undefined}
style={{
marginLeft: 4,
fontWeight: pageNumber === page ? 'bold' : 'normal',
}}
>
{pageNumber}
</button>
);
})}
<button
type="button"
onClick={() => handlePageChange(page + 1)}
disabled={page === totalPages}
style={{ marginLeft: 4 }}
>
Наступна
</button>
</nav>
</main>
);
}
export default App;Початкові параметри читаються з URL у функції getPaginationFromUrl:
const params = new URLSearchParams(window.location.search);Після цього значення перевіряються:
якщо size не входить до дозволених значень, використовується 10;
якщо page відсутній або некоректний, використовується 1;
якщо номер сторінки більший за максимальний, він обмежується останньою сторінкою.
Відображення частини масиву виконується за допомогою slice:
const startIndex = (page - 1) * pageSize;
const endIndex = startIndex + pageSize;
const visibleProducts = products.slice(startIndex, endIndex);Наприклад, для:
page = 3;
pageSize = 10;обчислення буде таким:
startIndex = 20;
endIndex = 30;Отже, будуть показані елементи з індексами від 20 до 29.
Для зміни адреси без перезавантаження сторінки використовується:
window.history.replaceState(null, '', nextSearch);Метод replaceState замінює поточний запис в історії браузера. Це зручно, якщо кожна зміна сторінки не повинна створювати окремий запис в історії.
Якщо потрібно, щоб кожна сторінка додавалася в історію браузера, можна використати:
window.history.pushState(null, '', nextSearch);У такому випадку натискання кнопки «Назад» повертатиме користувача на попередню сторінку пагінації.
Зміна URL за допомогою pushState або replaceState сама по собі не викликає оновлення React-компонента. Для реагування на навігацію браузера використовується подія popstate:
useEffect(() => {
function handlePopState() {
const nextPagination = getPaginationFromUrl();
setPage(nextPagination.page);
setPageSize(nextPagination.pageSize);
}
window.addEventListener('popstate', handlePopState);
return () => {
window.removeEventListener('popstate', handlePopState);
};
}, []);Обробник потрібно видаляти під час очищення ефекту. Інакше після повторного монтування компонента можуть залишитися зайві обробники подій.
Під час зміни pageSize поточну сторінку зазвичай потрібно скинути на першу:
function handlePageSizeChange(event) {
const nextPageSize = Number(event.target.value);
setPageSize(nextPageSize);
setPage(1);
}Це запобігає ситуації, коли користувач перебуває на сторінці, якої більше не існує після зміни розміру сторінки.
Наприклад:
при 10 елементах на сторінці існує 9 сторінок;
при 20 елементах на сторінці існує 5 сторінок.
Якщо користувач був на сторінці 9 і вибрав розмір 20, сторінка 9 стане недійсною.
Якщо набір даних великий, не варто завантажувати всі елементи в браузер. У такому випадку React передає серверу номер сторінки та її розмір:
/products?page=3&size=20Сервер повертає дані лише для третьої сторінки, а також загальну кількість елементів або сторінок.
Типова відповідь сервера може мати такий вигляд:
{
"items": [
{
"id": 41,
"name": "Товар 41"
}
],
"page": 3,
"size": 20,
"totalItems": 87
}У React параметри URL можна використовувати під час запиту:
useEffect(() => {
async function loadProducts() {
const response = await fetch(`/api/products?page=${page}&size=${pageSize}`);
const data = await response.json();
setProducts(data.items);
setTotalItems(data.totalItems);
}
loadProducts();
}, [page, pageSize]);У такій реалізації:
page і pageSize визначають параметри запиту;
сервер повертає тільки потрібні елементи;
totalItems використовується для обчислення totalPages;
при зміні сторінки виконується новий запит.
Клієнтська і серверна пагінація мають однакову логіку інтерфейсу. Основна відмінність полягає в тому, де зберігається повний набір даних.
Пагінація є навігаційним елементом сторінки, тому її варто позначити:
<nav aria-label="Пагінація">
{/* Кнопки сторінок */}
</nav>Для активної сторінки використовується атрибут:
<button aria-current="page">3</button>Кнопки переходу на попередню та наступну сторінки потрібно блокувати на відповідних межах:
<button disabled={page === 1}>Попередня</button>
<button disabled={page === totalPages}>Наступна</button>Це не лише покращує взаємодію з клавіатурою, а й запобігає некоректним значенням сторінки.
Користувач може вручну змінити URL, наприклад:
/products?page=-10&size=abcНе можна безпосередньо використовувати такі значення. Їх потрібно перетворити, перевірити та замінити значеннями за замовчуванням.
Правильний розрахунок початкового індексу:
const startIndex = (page - 1) * pageSize;Якщо використати page * pageSize, перша сторінка пропустить перші елементи.
key у спискуКожен елемент списку повинен мати стабільний ключ:
{visibleProducts.map((product) => (
<li key={product.id}>{product.name}</li>
))}Індекс масиву не завжди є хорошим ключем, особливо якщо список може змінюватися.
Після зміни pageSize поточна сторінка може стати недійсною. Найпростіше рішення — перейти на першу сторінку.
Для кількох тисяч сторінок не слід відображати окрему кнопку для кожної. У такому випадку використовують скорочений набір:
перша сторінка;
кілька сторінок навколо поточної;
остання сторінка;
розділювач ….
Це окрема логіка побудови елементів пагінації, але принцип перевірки меж залишається таким самим.
Пагінація показує користувачу лише частину великого набору даних.
Кількість сторінок обчислюється через Math.ceil(totalItems / pageSize).
Потрібний фрагмент масиву можна отримати за допомогою slice.
Номер сторінки та розмір сторінки зручно зберігати в параметрах URL.
URLSearchParams допомагає читати й оновлювати параметри адреси.
history.replaceState або history.pushState змінюють URL без перезавантаження.
Подія popstate дає змогу реагувати на навігацію кнопками браузера.
Для великих наборів даних краще використовувати серверну пагінацію.
Параметри URL потрібно перевіряти, а кнопки пагінації — блокувати на межах.