Как сделать Infinite Scroll на хуках в React приложении
Вступление
Infinite Scrolling - это популярный метод загрузки данных по мере необходимости (on-demand quests). В этой парадигме в начальном рендере приложение запрашивает только часть контента (только ту, которую он сможет увидеть) и динамически подгружает следующие части по мере прокрутки страницы пользователем, обеспечивая бесшовный user experience.
В этой статье описан самый простой способ реализации - на хуках.
Библиотека хуков React
reactuse - это самая большая коллекция хуков для React. Для реализации задумки из нее понадобится 2 хука:
useQuery - для создания запросов на сервер
useIntersectionObserver - для отслеживания нахождения DOM элемента в поле видимости пользователя
Для установки:
$ npm i @siberiacancode/reactuse --save
# or
$ yarn add @siberiacancode/reactuse
Фейковое API
Для демонстрации буду использовать mock api - https://pokeapi.co/
Запрос https://pokeapi.co/api/v2/pokemon/?limit=5&offset=0
выдаст первых 5 покемонов.
Реализация Infinite Scroll
создаем константу PORTION_OF_ITEMS, которая будет означать количество покемонов, которое будет возвращаться за один запрос. ( PORTION_OF_ITEMS = limit в запросе)
const PORTION_OF_ITEMS = 4;
function App() {}
Создаем состояние offset, в котором будет храниться сдвиг поиска покемонов (сдвигать будем на количество, равное PORTION_OF_ITEMS)
const [offset, setOffset] = useState<number>(0);
Импортируем хуки из reactuse
import { useIntersectionObserver, useQuery } from "@siberiacancode/reactuse";
Создаем запрос с помощью useQuery. В callback функцию указываем обычный fetch по запросу нашего api. (Важно: в объекте options у хука useQuery указаны keys: [offset], это нужно чтобы запрос отправлялся заново, если мы обновляем состояние offset). Также создаем стэйт pokemons.
В случае успеха (onSuccess) кидаем в массив pokemons и предыдущих покемонов, и запрошенных только что.
-> Получаем набор стэйтов: data, isLoading, isError, isSuccess, error.
const [pokemons, setPokemons] = useState<Pokemon[]>([]);
const { isLoading, isError, isSuccess, error } = useQuery(
() =>
fetch(
`https://pokeapi.co/api/v2/pokemon/?limit=${PORTION_OF_ITEMS}&offset=${offset}`
)
.then((res) => res.json())
.then((res) => res.results as Promise<Pokemon[]>),
{
keys: [offset],
onSuccess: (fetchedPokemons) => {
setPokemons((prevPokemons) => [...prevPokemons, ...fetchedPokemons]);
},
}
);
В случае isError отображаем сообщение ошибки. Если isLoading отображаем "Pending..."
Если все хорошо (isSuccess), то используем метод map() у массива переменной pokemons, чтобы отобразить всех покемонов.
После списка покемонов добавим div "Loading new...", далее мы будем на него ссылаться. Если он попадет в поле видимости, то произойдет загрузка данных.
if (isError)
return (
<div>
{error?.name}: {error?.message}
</div>
);
if (isLoading) return <div>Pending...</div>;
if (isSuccess)
return (
<div>
{pokemons.map((pokemon, index) => {
return (
<div key={index} className=" w-32 h-32">
<h1>{pokemon.name}</h1>
<img
alt={pokemon.name}
src={`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/${
index + 1
}.png`}
/>
</div>
);
})}
<div>Loading new...</div>
</div>
);
interface Pokemon {
name: string;
}
Теперь добавим useIntersectionObserver
Здесь мы получаем ref, который накинем на div элемент с текстом "Loading...", чтобы при появлении его в поле видимости приложение сразу обрабатывало следующий запрос.
в методе onChange мы проверяем, происходит ли пересечение. Если да - двигаем стэйт offset на значение, равное PORTION_OF_ITEMS. (так как мы ранее передали offset в keys, параметр хука useQuery, то новый запрос будет отправлен сразу же, как обновится состояние offset)
const { ref } = useIntersectionObserver<HTMLDivElement>({
threshold: 1,
onChange: (entry) => {
if (entry.isIntersecting) setOffset((prev) => prev + PORTION_OF_ITEMS);
},
});
<div ref={ref}>Loading...</div>
Теперь все готово
Весь код
import { useIntersectionObserver, useQuery } from "@siberiacancode/reactuse";
import { useState } from "react";
const PORTION_OF_ITEMS = 4;
interface Pokemon {
name: string;
}
function App() {
const [offset, setOffset] = useState<number>(0);
const [pokemons, setPokemons] = useState<Pokemon[]>([]);
const { isLoading, isError, isSuccess, error } = useQuery(
() =>
fetch(
`https://pokeapi.co/api/v2/pokemon/?limit=${PORTION_OF_ITEMS}&offset=${offset}`
)
.then((res) => res.json())
.then((res) => res.results as Promise<Pokemon[]>),
{
keys: [offset],
onSuccess: (fetchedPokemons) => {
setPokemons((prevPokemons) => [...prevPokemons, ...fetchedPokemons]);
},
}
);
const { ref } = useIntersectionObserver<HTMLDivElement>({
threshold: 1,
onChange: (entry) => {
if (entry.isIntersecting) setOffset((prev) => prev + PORTION_OF_ITEMS);
},
});
if (isError)
return (
<div>
{error?.name}: {error?.message}
</div>
);
if (isLoading && !pokemons.length) return <div>Pending...</div>;
if (isSuccess)
return (
<div>
{pokemons.map((pokemon, index) => {
return (
<div key={index} className=" w-32 h-32">
<h1>{pokemon.name}</h1>
<img
width={475}
alt={pokemon.name}
height={475}
src={`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/${
index + 1
}.png`}
/>
</div>
);
})}
<div ref={ref}>Loading...</div>
</div>
);
}
export default App;