Вступление

Infinite Scrolling - это популярный метод загрузки данных по мере необходимости (on-demand quests). В этой парадигме в начальном рендере приложение запрашивает только часть контента (только ту, которую он сможет увидеть) и динамически подгружает следующие части по мере прокрутки страницы пользователем, обеспечивая бесшовный user experience.

В этой статье описан самый простой способ реализации - на хуках.

Библиотека хуков React

reactuse - это самая большая коллекция хуков для React. Для реализации задумки из нее понадобится 2 хука:

  1. useQuery - для создания запросов на сервер

  2. 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;

reactuse - источники

npm - Github

Комментарии (5)


  1. CitizenOfDreams
    07.07.2024 04:35
    +1

    Почему-то бесконечный скролл делают где угодно, но только не там, где он был бы удобен пользователю. Например, когда ты в магазине что-то ищешь, получаешь 10 страниц по 10 товаров и переключаешься между ними как дурак.


    1. Dominux
      07.07.2024 04:35

      Инет-магазины часто реализуют засчет устаревших CMS и прочего


  1. Zukomux
    07.07.2024 04:35

    Ну вот зачем использовать какой-то кастомный пакет с кучей лишнего. Ну напиши, что это библиотека react-query, а не вот это вот. А потом удивляются, когда в проекте миллион зависимостей после установки одного пакета)


  1. Fanatos
    07.07.2024 04:35

    Спасибо! Сохранил в закладки :)


  1. CitizenOfDreams
    07.07.2024 04:35

    Да, кстати, отдельные лучи ненависти Озону, у которого история заказов сделана бесконечным скроллом, но через каждые 5 заказов вставлены "рекомендации".