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.

Удачного кодирования! Увидимся в следующей статье.