Привет, друзья!


В этой статье я хочу поделиться с вами опытом разработки хука для загрузки дополнительных данных (авось кому-нибудь пригодится).


На самом деле, хуков будет целых 2 штуки:


  • useLoadMore — для загрузки дополнительных данных при нажатии кнопки "Загрузить еще"
  • useLoadPage — для постраничной загрузки данных (аля пагинация на основе курсора)

Первый хук попроще, второй посложнее.


Полагаю, лишним будет говорить, что необходимость в использовании подобных хуков возникает в каждом втором проекте (по крайней мере, в моей сфере деятельности).


Исходный код проекта находится здесь.


Песочница:

Для запуска проекта в песочнице в терминале необходимо выполнить команду yarn start.


Команды для локального запуска проекта:


# клонируем репозиторий
git clone https://github.com/harryheman/use-load.git
# переходим в директорию с проектом
cd use-load

# устанавливаем общие зависимости
yarn

# устанавливаем зависимости для сервера
cd server && yarn

# устанавливаем зависимости для клиента
cd .. && cd client && yarn

# запускаем сервер для разработки
cd .. && yarn start

Скриншот страницы, на которой используется хук useLoadPage, для затравки:




Проект состоит из двух частей:


  • сервера для генерации фиктивных данных и обработки запросов, поступающих от клиента;
  • клиента для выполнения запросов и отображения данных, полученных от сервера.

Сервер написан на Node, клиент — на React и TypeScript.


Структура проекта:




Код сервера состоит из 2 файлов:


  • index.js — код для "роутов" и express-сервера
  • seed.js — код для генерации фиктивных данных с помощью faker

Фиктивные данные представляют собой массив из 100 товаров (allItems). Каждый товар — это примерно такой объект:


{
 "id": "5b45a471-3429-4bde-bf0e-1750e84fd4bd",
 "title": "Generic Plastic Chair",
 "description": "Ergonomic executive chair upholstered in bonded black leather and PVC padded seat and back for all-day comfort and support",
 "price": "940.00",
 "image": "http://placeimg.com/640/480/tech?82281"
}

Товары разделены на 10 страниц (allPages). Каждая страница — это объект, ключом которого является номер страницы, а значением — массив из 10 товаров:


// pages
{
 1: [
   {
     // item
   },
 ],
 // etc.
}

После генерации фиктивные данные записываются в файл server/data/fake.json.


Сервер обрабатывает запросы к 3 конечным точкам:


  • /all-items: в ответ возвращаются все товары — { items: allItems }
  • /more-items: в ответ возвращается часть товаров, начиная с первого и заканчивая номером страницы из строки запроса (req.query), умноженной на 10, а также общее количество страниц — { items: allItems.slice(0, page * 10), totalPages: Object.keys(allPages).length }
  • /items-by-page: в ответ возвращается 10 товаров, соответствующих номеру страницы из строки запроса, а также общее количество страниц — { items: allPages[page], totalPages: Object.keys(allPages).length }

В целом, это все, что касается сервера.


На клиенте у нас имеется следующее:


  • API для взаимодействия с сервером (api/index.ts), включающее 3 функции, соответствующие 3 конечным точкам на сервере:
    • fetchAllItems — функция для получения всех товаров
    • fetchItemsAndPages и fetchItemsByPage — функции для получения части товаров на основе номера (значения) страницы
  • 3 страницы (pages), соответствующие 3 API-функциям:
    • AllProducts.tsx — страница для отображения всех товаров. На этой странице используется функция fetchAllItems. Роут для страницы — /
    • MoreProducts.tsx — страница для отображения части товаров с возможностью загрузки дополнительных товаров при нажатии кнопки "Загрузить еще" в виде "????". На этой странице используется хук useLoadMore, которому в качестве аргумента передается функция fetchItemsAndPages. Здесь мы двигаемся только вперед. Роут для страницы — /more-products
    • ProductsByPage.tsx — страница для постраничного отображения товаров с возможностью переключения страниц при нажатии кнопок "Вперед" в виде "????" или "Назад" в виде "????". На этой странице используется хук useLoadPage, которому в качестве аргумента передается функция fetchItemsByPage. В данном случае мы можем двигаться в обоих направлениях, т.е. как вперед, так и назад. Роут для страницы — /products-by-page
  • 3 компонента (components):
    • Navbar.tsx — панель навигации для переключения между страницами приложения
    • ProductCard.tsx — карточка товара
    • ProductList.tsx — список товаров
  • 2 хука (hooks):
    • useLoadMore.ts — хук для загрузки дополнительных товаров
    • useLoadPage.ts — хук для загрузки товаров, соответствующих определенной странице

Перейдем непосредственно к рассмотрению хуков.


Начнем с более простого — useLoadMore.


Импортируем хуки из react и react-router-dom, а также типы для функции получения товаров и объекта товара из types.ts:


import { useEffect, useRef, useState } from 'react'
import { useHistory } from 'react-router-dom'
import { Item, FetchItems } from 'types'

Типы выглядят так:


export type Item = {
 id: string
 title: string
 description: string
 price: number
 image: string
}

export type AllItems = {
 items: Item[]
}

export type ItemsAndPages = AllItems & {
 totalPages: number
}

export type FetchItems = (page: number) => Promise<ItemsAndPages>

Определяем тип для объекта, возвращаемого хуком:


type UseLoadMoreReturn = {
 loading: boolean
 items: Item[]
 loadMore: () => void
 hasMore: boolean
}

Как мы видим, хук возвращает следующее:


  • индикатор загрузки;
  • товары;
  • метод для загрузки дополнительных товаров;
  • индикатор наличия товаров.

Определяем хук:


// хук принимает единственный параметр - функцию для получения дополнительных данных
export const useLoadMore = (fetchItems: FetchItems): UseLoadMoreReturn => {
 // код хука
}

Определяем переменные для товаров, значения (номера) текущей страницы, всех (доступных) страниц и индикатора загрузки, а также получаем объект истории браузера:


// товары
const [items, setItems] = useState<Item[]>([])
// значение текущей страницы либо извлекается из строки запроса,
// например, `?page=1`, либо устанавливается в значение 1
const page = Number(new URLSearchParams(window.location.search).get('page'))
const currentPage = useRef(page > 0 ? page : 1)
// все страницы
const allPages = useRef(0)
// индикатор загрузки
const [loading, setLoading] = useState(false)

// объект истории
const history = useHistory()

Определяем функцию (внутренний метод) для загрузки товаров:


// функция принимает единственный параметр - номер страницы
async function loadItems(page: number) {
 setLoading(true)

 try {
   const { items, totalPages } = await fetchItems(page)

   setItems(items)

   // меняем значение переменной только при инициализации и изменении данных из ответа сервера
   if (allPages.current !== totalPages) {
     allPages.current = totalPages
   }
 } catch (e) {
   console.error(e)
 } finally {
   setLoading(false)
 }
}

Выполняем однократный побочный эффект для загрузки товаров на основе значения текущей страницы:


useEffect(() => {
 loadItems(currentPage.current)
 // eslint-disable-next-line
}, [])

Определяем функцию (публичный интерфейс) для загрузки дополнительных товаров:


function loadMore() {
 // код функции выполняется только при условии, что значение текущей страницы
 // меньше количества доступных страниц
 if (currentPage.current < allPages.current) {
   // в данном случае мы двигаемся только в одном направлении
   // поэтому следующая страница - это всегда текущая страница + 1
   const nextPage = currentPage.current + 1

   // обновляем значение текущей страницы
   currentPage.current = nextPage

   // манипулируем адресом страницы
   history.replace(`?page=${nextPage}`)

   // загружаем товары
   loadItems(nextPage)
 }
}

Зачем мы манипулируем адресом страницы? На это существует, как минимум, 2 причины:


  • это позволяет пользователю вернуться к тому списку, с которого он ушел, например, переключившись на страницу товара (в проекте не реализовано, но это можно увидеть, если переключиться на другую страницу и нажать "Назад" в браузере). В идеале, на странице также должно быть реализовано восстановление прокрутки (scroll restoration), но это тема для отдельной статьи;
  • это позволяет поделиться ссылкой на товар, который находится дальше первой страницы. Если, например, перейти по прямой ссылке more-products?pages=3, то будет загружено не 10, а 30 первых товаров (здесь опять же не хватает привязки к конкретному товару).

Наконец, возвращаем объект:


return {
 loading,
 items,
 loadMore,
 // обратите внимание, что здесь мы должны использовать `<`, а не `<=`
 hasMore: currentPage.current < allPages.current
}

Пример использования этого хука можно увидеть на странице MoreProducts.tsx.


При нажатии кнопки ???? вызывается функция loadMore из хука. Когда индикатор наличия товаров получается значение false, эта кнопка не рендерится. Данный индикатор также можно установить в качестве значения атрибута disabled кнопки. Даже если разблокировать кнопку через редактирование разметки, загрузки несуществующих товаров не произойдет благодаря проверке currentPage.current < allPages.current.


Теперь рассмотрим более продвинутый хук — useLoadPage.


В начале мы также импортируем хуки и типы:


import { useEffect, useRef, useState } from 'react'
import { useHistory } from 'react-router-dom'
import { Item, FetchItems } from 'types'

Определяем тип для возвращаемого хуком объекта:


type UseLoadPageReturn = {
 loading: boolean
 items: Item[]
 hasNext: boolean
 hasPrev: boolean
 loadNext: () => void
 loadPrev: () => void
 currentPage: number
 allPages: number
 loadPage: (page: number) => void
}

Как мы видим, хук возвращает много всего интересного:


  • loading — индикатор загрузки
  • items — товары
  • hasNext — индикатор наличия следующей страницы товаров
  • hasPrev — индикатор наличия предыдущей страницы
  • loadNext — функция для загрузки следующей страницы
  • loadPrev — функция для загрузки предыдущей страницы
  • currentPage — текущая страница
  • allPages — все страницы
  • loadPage — функция для загрузки товаров, соответствующих определенной странице

В дополнение к этому я решил реализовать кеширование загруженных ранее страниц товаров. Определяем тип для соответствующего объекта:


type PagesCache = {
 [page: string]: Item[]
}

Приступаем к реализации хука:


// хук принимает единственный параметр - функцию для получения дополнительных данных
export const useLoadPage = (fetchItems: FetchItems): UseLoadPageReturn => {
 // код хука
}

Определяем переменные для товаров, кеша, текущей страницы, первой страницы, всех страниц и индикатора загрузки, а также получаем объект истории браузера:


// товары
const [items, setItems] = useState<Item[]>([])
// кеш для товаров
const cachedItems = useRef<PagesCache>({})
// значение текущей страницы либо извлекается из строки запроса,
// например, `?page=1`, либо устанавливается в значение 1
const page = Number(new URLSearchParams(window.location.search).get('page'))
const currentPage = useRef(page > 0 ? page : 1)
// первая страница - хак (см. ниже)
const firstPage = useRef(Infinity)
// все страницы
const allPages = useRef(0)
// индикатор загрузки
const [loading, setLoading] = useState(false)

// объект истории
const history = useHistory()

Определяем функцию (внутренний метод) для загрузки товаров:


// функция принимает единственный параметр - номер страницы
async function loadItems(page: number) {
 // если для переданной страницы в кеше имеются товары
 if (cachedItems.current[page]) {
   // возвращаем их без выполнения запроса к серверу
   return setItems(cachedItems.current[page])
 }

 setLoading(true)

 try {
   const { items, totalPages } = await fetchItems(page)

   setItems(items)

   // записываем загруженные товары в кеш - ключом является номер страницы
   cachedItems.current[page] = items

   // обновляем значения переменных для всех и первой страницы
   if (allPages.current !== totalPages) {
     allPages.current = totalPages
     firstPage.current = 1
   }
 } catch (e) {
   console.error(e)
 } finally {
   setLoading(false)
 }
}

Выполняем однократный побочный эффект для загрузки товаров на основе значения текущей страницы:


useEffect(() => {
 loadItems(currentPage.current)
 // eslint-disable-next-line
}, [])

Определяем функцию (публичный интерфейс) для загрузки товаров по номеру страницы:


// функция принимает единственный параметр - номер страницы
function loadPage(page: number) {
 currentPage.current = page

 history.replace(`?page=${page}`)

 loadItems(page)
}

Определяем функции (публичный интерфейс) для загрузки следующей и предыдущей страниц товаров:


function loadNext() {
 // код функции выполняется только при условии, что значение текущей страницы
 // меньше количества доступных страниц
 if (currentPage.current < allPages.current) {
   const nextPage = currentPage.current + 1

   loadPage(nextPage)
 }
}

function loadPrev() {
 // код функции выполняется только при условии, что значение текущей страницы
 // больше значения первой страницы
 if (currentPage.current > firstPage.current) {
   const nextPage = currentPage.current - 1

   loadPage(nextPage)
 }
}

Наконец, возвращаем объект:


return {
 loading,
 items,
 hasNext: currentPage.current < allPages.current,
 hasPrev: currentPage.current > firstPage.current,
 loadNext,
 loadPrev,
 currentPage: currentPage.current,
 allPages: allPages.current,
 loadPage
}

Хак с первой страницей нужен для первоначального рендеринга страницы ProductsByPage, когда мы начинаем со второй и далее страницы. Если определить первую страницу явно (т.е. как 1), то индикатор hasPrev получит значение true и мы увидим заблокированную кнопку "Назад" над "лоадером". Попробуйте поэкспериментировать. Возможно, вы найдете лучшее решение.


Пример использования данного хука можно увидеть на странице ProductsByPage. Вот для чего используется каждое из возвращаемых хуком значений:


  • loading — если имеет значение true, вместо списка товаров рендерится лоадер, кнопки для переключения между страницами блокируются
  • items — передаются в качестве пропа компоненту ProductList для отображения списка товаров
  • hasNext — если имеет значение true, кнопка для загрузки следующей страницы товаров не рендерится
  • hasPrev — не рендерится кнопка для загрузки предыдущей страницы товаров
  • loadNext — вызывается при нажатии кнопки ????
  • loadPrev — вызывается при нажатии кнопки ????
  • currentPage — используется при формировании компонента PageLinks для определения текущей страницы товаров и ее визуальной индикации
  • allPages — используется при формировании компонента PageLinks для определения общего количества "ссылок"
  • loadPage — вызывается при клике по ссылке из PageLinks

На странице ProductsByPage также реализовано переключение между страницами товаров при нажатии стрелок на клавиатуре.


Пожалуй, это все, чем я хотел поделиться с вами в данной статье.


Буду рад любой форме обратной связи.


Благодарю за внимание и хорошего дня!




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


  1. faiwer
    22.10.2021 13:18
    +2

    Пара советов:


    1. Обратите внимание на useReducer. Проще хранить всё в одном объекте (все эти page и пр. штуки, которые вы почему-то храните в ref-ах), и менять разом.
    2. Ещё стоит пресекать race-condition. Если проект большой и важный, то без этого вообще никак. Правда это само по себе — тема для целой статьи.
    3. Обработку ошибок делать более централизовано. console.error-ы раскиданные по коду это плохо. И вообще хорошо бы уметь возвращать её (ошибку) из хука.
    4. Кеш лучше делать опциональным (или вообще не делать), т.к. это далеко не всегда желаемое поведение.
    5. Ещё я бы выпилил всё что касается URL из этого хука, т.к. вы таким образом нарушаете принцип ограниченной ответственности. Такие вещи должны быть извне.
    6. Не увидел никакой мемоизации. Это странно для хука базового назначения.

    Мы используем похожую, но кратно более сложную схему.


    P.S. ещё можно посмотреть в сторону отказа от page, в пользу курсоров.


    1. faiwer
      22.10.2021 13:25

      return {
        currentPage: currentPage.current,
        allPages: allPages.current,
      }

      Вот тут грубая ошибка. Либо у вас это реактивный state, и тогда вы можете вернуть это из хука наружу. Либо это не реактивный стейт, и тогда render vDom древа ни в коем случае не должен от таких значений зависеть. Т.е. не используйте useRef для рективных вещей. Для этого есть useState и useReducer (ну и всякие mobX и прочие внешние сторы).


      Возможно у вас сейчас этот баг никак не проявляется ввиду того, что помимо currentPage и allPages обновляется что-нибудь ещё и соответственно необходимый render всё равно происходит. Но на такие вещи точно нельзя полагаться. Это скорее из области "случайно работает".


      Если вы используете useRef вместо useState только чтобы избежать лишних ререндеров — useReducer или unstable_... вам в помощь. А сейчас вы ходите по минному полю.


      1. aio350 Автор
        23.10.2021 12:39

        Ты же не думаешь, что я представил общественности первые версии хуков?) Изначально `currentPage` и `allPages` не должны были быть частью публичного интерфейса. Когда в этом возникла необходимость, я начал с `useState`. Результатом стал многократный повторный рендеринг при переключении страницы (`hasPrev` и `hasNext` также были реактивными). Потом я понял, что любое взаимодействие с хуком - это вызов `loadItems`, который влечет повторное вычисление кода хука за счет обновления состояния `items` или, в крайнем случае, `loading`.


        1. faiwer
          23.10.2021 20:59
          +1

          Перечитай внимательно комментарий выше. Использование useRef для реактивных значений — грубая ошибка. Ибо это бомба замедленного действия. Тот самый скользкий тип багов, которые потом тяжело воспроизводить и выяснять причины странного поведения. Один из самых дорогостоящих видов багов для бизнеса.


          Да я вижу, что смена items всё равно вызывает ререндер, но на такие вещи полагаться нельзя. Ни в коем случае. Минимальный рефакторинг в будущем, когда человек не будет иметь всей картинки костылей в голове, легко поломает этот "код". И да, текущее "случайно" работающее поведение это как раз костыль. Красный флаг.


          Результатом стал многократный повторный рендеринг

          Вот чтобы таких вещей не было надо вникать в то как хуки работают, какие задачи они выполняют, и каков вообще hook way в реакте. Судя по всему (по твоим ответам и коду в статье) ты пока пишешь "на ощупь". Отсюда и типовые ошибки и типовые костыли. Серьёзно, я не хочу обидеть, просто это видно издалека.


          hasPrev и hasNext также были реактивными

          Вот это тоже грубая ошибка. Которую, насколько я понял, ты уже усвоил. Тут действует простое правило — всё что можно посчитать на основе уже существующих данных — не нужно хранить в стейте. Максимум мемоизировать (useMemo), если вычисления тяжёлые. Причина банальна — ручная синхронизация = новый источник багов = дорого. В твоём случае ещё и rerender-ы.


          многократный повторный рендеринг

          Нет смысла, оптимизация будет преждевременной

          За что боролся на то и напоролся. Когда пишешь core-вещи, т.е. обобщённый многократно переиспользуемый код (а твой хук как раз из таких), то это должна быть вылизанная до мелочей оптимизированная штука. Иначе — руки прочь из core части. Даже вопроса такого не должно возникать.


          Я бы не сказал, что "хранить все в одном объекте" всегда проще

          А где я сказал "всегда"? У нас на 80к строк кода всего несколько useReducer. У тебя как раз такой случай, когда useReducer упрощает понимание кода, убирает лишние рендеры, легко scale-уется в случае сложных доработок. То, что доктор прописал. Да ещё и все переменные тесно связанные между собой. ​Особенно если учесть что в настоящем боевом коде этот хук будет куда сложнее, когда полезут corner case-ы.


          1. Возможно.

          Не возможно, а точно, я тебе говорю. На этапе system design такой ответ это красный флаг и "мы вам перезвоним". Тебе завтра потребуется подключить этот хук в другую часть приложения где более сложная работа с URL (или просто другая) и тебе придётся выпиливать всё до последней буквы. Да даже просто наличие на странице сразу двух постраничных виджетов (или списка списокв) и "приехали".


          А причина банальная — это не задача для хука который занимается вызовом асинхр. метода который подтягивает данные согласно постраничной навигации. "low in coupling and high in cohesion" — вот главная мантра любой архитектуры. Из неё автоматически вытекает что не должно быть таких пучков которые умеют во всё сразу, особенно как-то конкретно (?page= в любой URL игнорируя рутинг приложения).


    1. beDenz
      22.10.2021 13:44

      P.S. ещё можно посмотреть в сторону отказа от page, в пользу курсоров.

      Что вы имеете ввиду под "курсором"?


      1. faiwer
        22.10.2021 13:55
        +1

        Когда вместо SELECT ... OFFSET {(page - 1) * limit} LIMIT {limit} используются более сложные схемы. Например берётся items.last().createdBy.toUnixTime() и возвращается в качестве курсора\якоря\как-угодно-можно-назвать. А на сервере WHERE createdBy >= {cursor}.


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


    1. aio350 Автор
      23.10.2021 12:29

      Спасибо за ревью, друг.
      1. Я бы не сказал, что "хранить все в одном объекте" всегда проще.
      2. По поводу гонки условий ты (ничего, что я на ты?) прав. Добавил парочку условий.
      3. Согласен, но это всего лишь пример хука, а не полноценное приложение. Думаю, что возвращение ошибки лучше оставить `fetchItems`.
      4. Согласен, мне просто хотелось показать, как это можно сделать. Если в качестве `fetchItems` использовать `swr`, например, то за кеширование будет отвечать хук `useSWR`.
      5. Возможно.
      6. Нет смысла, оптимизация будет преждевременной.


  1. namikiri
    22.10.2021 16:25
    +1

    Автор статьи, уберите, пожалуйста, виджет StackBlitz под кат. Иначе он передёргивает фокус на себя и главная Хабра автоматом прокручивается на него при каждом открытии.


    1. faiwer
      22.10.2021 16:47
      +1

      А лучше под <spoiler/>, чтобы не только главная не прыгала, но ещё и сама страница статьи.