React Query — одна из самых надежных библиотек в экосистеме React, которая решает множество задач, связанных с получением, обновлением, кэшированием и повторными попытками.
Бесконечная прокрутка — это распространенный шаблон загрузки фрагментов данных по мере того, как пользователь прокручивает содержимое до конца. Типичным примером является лента новостей Facebook, где вы продолжаете прокручивать, потому что ей нет конца. Он часто используется для обеспечения более удобного и привлекательного просмотра для пользователей. Другим распространенным шаблоном является разбиение на страницы.
В этой статье мы будем загружать множество задач из JSONPlaceholder. JSONPlaceholder – это бесплатный онлайн-API REST, который вы можете использовать всякий раз, когда вам нужны поддельные данные. Вот что мы собираемся построить:
Монтаж
Вы можете клонировать стартовый проект с GitHub. В этой статье мы сосредоточимся исключительно на реализации React Query для бесконечной прокрутки, поэтому я уже установил необходимые пакеты в начальном репозитории. Вы можете попробовать это, используя следующие команды:
git clone -b starter https://github.com/bicky-tmg/react-query-infinite-scrolling.git cd react-query-infinite-scrolling npm install
Если вы посмотрите на App.js и кучу файлов, вы можете заметить, что React Query был настроен для вас, а также был сделан пример реализации. Нажмите npm run start
в терминале. Посетите http://locahost:3000
, если не перенаправлены в браузер и вуаля.
React Query поддерживает полезную версию хука useQuery
под названием useInfiniteQuery
для рендеринга списка дополнительных данных в существующий набор данных, что является распространенным шаблоном, известным как «бесконечная прокрутка». Хук useInfiniteQuery
включает в себя кучу опций и возвращает объект, который содержит кучу полезных свойств. Мы обсудим только те параметры и свойства, которые будем реализовывать в этой статье.
Параметры
queryKey
: Ключи запроса уникальны для данных запроса. Это может быть как простой массив с одной строкой, так и сложный массив из множества строк и вложенных объектов.
queryFn
: Функция, которую запрос будет использовать для запроса данных. В нашем случае мы будем передавать pageParams
со значением по умолчанию 1 в queryFn
.
getNextPageParam
: Это помогает определить, есть ли еще данные для загрузки и информация для их извлечения. Эта информация представляет собой либо одну переменную, которая предоставляется в качестве дополнительного параметра в функции запроса, либо undefined
указывает, что следующая страница недоступна. При получении новых данных для этого запроса эта функция получает как последнюю страницу бесконечного списка данных, так и полный массив всех страниц.
Возврат
data
: возвращаемые данные представляют собой объект, содержащий бесконечные данные запроса. Объект состоит из;
data.pages
массив, содержащий выбранные страницыdata.pageParams
массив, содержащий параметры страницы, используемые для выборки страниц.
hasNextPage
: это логическое значение true
, если getNextPageParam
возвращает значение, отличное от undefined
.
fetchNextPage
: Эта функция позволяет получить следующую «страницу» результатов. Вы можете вручную указать pageParam вместо использования getNextPageParam
.
isFetchingNextPage
: это логическое значение true
при загрузке следующей страницы с помощью fetchNextPage
. Это помогает различать состояние фонового обновления и состояние дополнительной загрузки.
Теперь, когда у нас есть основная информация обо всех параметрах и возвращаемых свойствах хука useInfiniteQuery
, давайте приступим к проекту.
Давайте начнем
В файле App.js замените импорт хука useQuery
на хук useInfiniteQuery
. Затем нам нужно внести изменения в функцию запроса fetchTodos
, как показано ниже:
// file: App.js function App() { const LIMIT = 10; const fetchTodos = async (page) => { const response = await fetch( `https://jsonplaceholder.typicode.com/todos?_page=${page}&_limit=${LIMIT}` ); return response.json(); }; ...
Здесь fetchTodos
принимает page
в качестве аргумента и будет получать задачи на странице. Мы также добавили константу LIMIT, равную 10, которая ограничивает размер страницы до 10. Извлекаемых данных будет не более 10. Затем полностью замените хук useQuery
приведенным ниже кодом:
// file: App.js const { data, isSuccess, hasNextPage, fetchNextPage, isFetchingNextPage } = useInfiniteQuery("todos", ({ pageParam = 1 }) => fetchTodos(pageParam), { getNextPageParam: (lastPage, allPages) => { const nextPage = lastPage.length === LIMIT ? allPages.length + 1 : undefined; return nextPage; }, });
Вау! Что за чертовщина? Вам не нужно паниковать, давайте разберем код шаг за шагом. Хук useInfiniteQuery
принимает три аргумента: Первый аргумент — это строка, представляющая ключ запроса, который используется для идентификации запроса в кэше. Второй аргумент — это функция, которая принимает объект, содержащий свойство pageParam
(которое по умолчанию равно 1, если не указано), и возвращает обещание, которое разрешается в todos для этой страницы. Третий аргумент — это объект, содержащий опции для запроса, такие как функция getNextPageParam
, указывающая, доступна ли следующая страница или нет. Функция получает как последнюю страницу бесконечного списка данных, так и полный массив всех страниц. Функция проверяет, имеет ли массив lastPage
длину LIMIT
, указывающую, что последняя страница была полной страницей задач. Затем он устанавливает nextPage
в allPages.length + 1
, указывая на то, что есть как минимум еще одна страница для выборки. В противном случае он устанавливает nextPage
в undefined
, указывая на то, что больше нет страниц для выборки. Это может быть не лучшая логика на данный момент, но она работает для нас. Возвращаемое значение nextPage
передается в queryFn
в нашем случае в fetchTodos
под капотом React Query. Теперь нам нужно изменить способ отображения объекта data
в нашем пользовательском интерфейсе.
// file: App.js function App() { ... return ( <div className="app"> {isSuccess && data.pages.map((page) => page.map((todo, i) => ( <article className="article"> <h2>{todo.title}</h2> <p>Status: {todo.completed ? "Completed" : "To Complete"}</p> </article> )) )} </div> ); }
С этими изменениями выбранные задачи должны отображаться в браузере. До сих пор мы получали задачи с помощью хука useInfiniteQuery
. Мы заложили основу для работы бесконечной прокрутки. Чтобы это работало, нам нужно вызывать хук fetchNextPage
всякий раз, когда мы прокручиваем вниз до последней выбранной задачи. Чтобы достичь этого, мы будем использовать пакет под названием react-intersection-observer
, это реализация React API Intersection Observer, чтобы сообщать вам, когда элемент входит или выходит из области просмотра. Давайте установим пакет с помощью следующей команды:
npm i react-intersection-observer
После того, как пакет был установлен, нам нужно импортировать пакет. Прямо под нашим импортом React Query импортируйте react-intersection-observer
:
// file: App.js import { useInfiniteQuery } from "react-query"; import { useInView } from "react-intersection-observer";
React Intersection Observer предоставляет нам хук useInView
. Хук useInView
позволяет легко отслеживать inView
состояние компонентов. Он вернет массив, содержащий ref
, статус inView
и текущий entry
. Нам нужно присвоить ref
элементу DOM, который мы хотим отслеживать, и хук сообщит о статусе.
// file: App.js function App() { const { ref, inView } = useInView(); const LIMIT = 10; ...
В нашем случае нам нужно прикрепить ref
к последней выбранной задаче. Но перед этим создадим отдельный компонент Todo
. Создайте файл Todo.js и вставьте приведенный ниже код:
// file: Todo.js import React from "react"; const Todo = React.forwardRef(({ todo }, ref) => { const todoContent = ( <> <h2>{todo.title}</h2> <p>Status: {todo.completed ? "Completed" : "To Complete"}</p> </> ); const content = ref ? ( <article className="article" ref={ref}> {todoContent} </article> ) : ( <article className="article">{todoContent}</article> ); return content; }); export default Todo;
Здесь, поскольку мы будем назначать ref
из хука useInView
, нам нужно перенаправить ссылку на компонент Todo
из компонента App
. Переадресация ссылки — это опциональная функция, которая позволяет некоторым компонентам принимать ref
, который они получают, и передавать его дальше (другими словами, «пересылать» его) дочернему элементу. React передает ref
функции ({todo}, ref) => ...
внутри forwardRef
в качестве второго аргумента. Мы перенаправляем этот аргумент ref
вниз к <article className=”article” ref={ref}>
, указав его как атрибут JSX. Когда ссылка прикреплена, ref.current
будет указывать на узел <article>
DOM. Так как мы не хотим прикреплять ref
к списку задач, только последнюю задачу из выбранных списков, которые будут рассмотрены позже. В приведенном ниже коде описывается реализация присоединения, если ref
было передано, прикрепите ref
, иначе нет.
const content = ref ? ( <article className="article" ref={ref}> {todoContent} </article> ) : ( <article className="article">{todoContent}</article> );
Теперь в App.js нам нужно импортировать компонент Todo и внести изменения следующим образом:
// file: App.js import { useInfiniteQuery } from "react-query"; import { useInView } from "react-intersection-observer"; import Todo from "./Todo"; import "./App.css"; function App() { const { ref, inView } = useInView(); const LIMIT = 10; const fetchTodos = async (page) => { const response = await fetch( `https://jsonplaceholder.typicode.com/todos?_page=${page}&_limit=${LIMIT}` ); return response.json(); }; const { data, isSuccess, hasNextPage, fetchNextPage, isFetchingNextPage } = useInfiniteQuery("todos", ({ pageParam = 1 }) => fetchTodos(pageParam), { getNextPageParam: (lastPage, allPages) => { const nextPage = lastPage.length === LIMIT ? allPages.length + 1 : undefined; return nextPage; }, }); const content = isSuccess && data.pages.map((page) => page.map((todo, i) => { if (page.length === i + 1) { return <Todo ref={ref} key={todo.id} todo={todo} />; } return <Todo key={todo.id} todo={todo} />; }) ); return ( <div className="app"> {content} </div> ); } export default App;
Какие изменения мы сделали? Мы импортировали компонент Todo и немного обновили содержимое JSX.
// file: App.js const content = isSuccess && data.pages.map((page) => page.map((todo, i) => { if (page.length === i + 1) { return <Todo ref={ref} key={todo.id} todo={todo} />; } return <Todo key={todo.id} todo={todo} />; }) ); return ( <div className="app"> {content} </div> );
if (page.length === i + 1)
указывает, что мы присоединяем ref
к последней задаче из списка выбранных задач. Если это последнее задание из списка, добавьте ref
, иначе нет. Мы почти в конце этой статьи. Теперь все, что осталось, — это отслеживать последний узел article
dom, к которому подключен ref
, используя состояние inView
, возвращаемое хуком useInView
, и если оно hasNextPage
, мы будем fetchNextPage
. Мы можем добиться этого с помощью кода ниже:
// file: App.js useEffect(() => { if (inView && hasNextPage) { fetchNextPage(); } }, [inView, fetchNextPage, hasNextPage]);
Нам также нужно знать, когда наши данные извлекаются, поэтому мы можем добавить загрузчик, указывающий, что Todo извлекается с помощью логического значения isFetchingNextPage
, предоставляемого хуком useInfiniteQuery
:
// file: App.js func App() { ... return ( <div className="app"> {content} {isFetchingNextPage && <h3>Loading...</h3>} </div> ); }
Подводя итог тому, что мы сделали до сих пор в файле App.js
:
// file: App.js import { useEffect } from "react"; import { useInfiniteQuery } from "react-query"; import { useInView } from "react-intersection-observer"; import Todo from "./Todo"; import "./App.css"; function App() { const { ref, inView } = useInView(); const LIMIT = 10; const fetchTodos = async (page) => { const response = await fetch( `https://jsonplaceholder.typicode.com/todos?_page=${page}&_limit=${LIMIT}` ); return response.json(); }; const { data, isSuccess, hasNextPage, fetchNextPage, isFetchingNextPage } = useInfiniteQuery("todos", ({ pageParam = 1 }) => fetchTodos(pageParam), { getNextPageParam: (lastPage, allPages) => { const nextPage = lastPage.length === LIMIT ? allPages.length + 1 : undefined; return nextPage; }, }); useEffect(() => { if (inView && hasNextPage) { fetchNextPage(); } }, [inView, fetchNextPage, hasNextPage]); const content = isSuccess && data.pages.map((page) => page.map((todo, i) => { if (page.length === i + 1) { return <Todo ref={ref} key={todo.id} todo={todo} />; } return <Todo key={todo.id} todo={todo} />; }) ); return ( <div className="app"> {content} {isFetchingNextPage && <h3>Loading...</h3>} </div> ); } export default App;
При этом новые задачи будут загружаться, когда мы прокручиваем страницу вниз в нашем приложении.
Примечание. Для лучшего взаимодействия с пользователем вместо определения видимости последнего элемента/задачи для получения следующего списка задач, если он доступен, мы можем прикрепить
ref
к третьему последнему элементу/задаче, чтобы, когда третий последний элемент/ задача видна, мы уже получаем следующий список задач, и пользователю не придется ждать состояния загрузки, что обеспечивает плавную и беспроблемную работу. Все, что нам нужно сделать, это изменить логику в App.js.
От
if (page.length === i + 1)
to
if (page.length >= 3 && page.length — 3 === i
Заключение
В этой статье мы рассмотрели, как реализовать бесконечную прокрутку с помощью React Query вместе с очень простым в использовании пакетом React Intersection Observer.
Для следующих шагов вы можете погрузиться на страницу официальной документации React Query, чтобы изучить более сложные концепции.
Если вам интересно посмотреть, как будет выглядеть код в целом, вы можете заглянуть в репозиторий GitHub.
Удачного кодирования! Увидимся в следующей статье.