
Привет, друзья!
В этой статье я хочу поделиться с вами опытом разработки хука для загрузки дополнительных данных (авось кому-нибудь пригодится).
На самом деле, хуков будет целых 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), соответствующие 3API-функциям:-
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)

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

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

faiwer
Пара советов:
useReducer. Проще хранить всё в одном объекте (все этиpageи пр. штуки, которые вы почему-то храните в ref-ах), и менять разом.console.error-ы раскиданные по коду это плохо. И вообще хорошо бы уметь возвращать её (ошибку) из хука.Мы используем похожую, но кратно более сложную схему.
P.S. ещё можно посмотреть в сторону отказа от
page, в пользу курсоров.faiwer
Вот тут грубая ошибка. Либо у вас это реактивный state, и тогда вы можете вернуть это из хука наружу. Либо это не реактивный стейт, и тогда render vDom древа ни в коем случае не должен от таких значений зависеть. Т.е. не используйте
useRefдля рективных вещей. Для этого естьuseStateиuseReducer(ну и всякиеmobXи прочие внешние сторы).Возможно у вас сейчас этот баг никак не проявляется ввиду того, что помимо
currentPageиallPagesобновляется что-нибудь ещё и соответственно необходимыйrenderвсё равно происходит. Но на такие вещи точно нельзя полагаться. Это скорее из области "случайно работает".Если вы используете
useRefвместоuseStateтолько чтобы избежать лишних ререндеров —useReducerилиunstable_...вам в помощь. А сейчас вы ходите по минному полю.aio350 Автор
Ты же не думаешь, что я представил общественности первые версии хуков?) Изначально `currentPage` и `allPages` не должны были быть частью публичного интерфейса. Когда в этом возникла необходимость, я начал с `useState`. Результатом стал многократный повторный рендеринг при переключении страницы (`hasPrev` и `hasNext` также были реактивными). Потом я понял, что любое взаимодействие с хуком - это вызов `loadItems`, который влечет повторное вычисление кода хука за счет обновления состояния `items` или, в крайнем случае, `loading`.
faiwer
Перечитай внимательно комментарий выше. Использование
useRefдля реактивных значений — грубая ошибка. Ибо это бомба замедленного действия. Тот самый скользкий тип багов, которые потом тяжело воспроизводить и выяснять причины странного поведения. Один из самых дорогостоящих видов багов для бизнеса.Да я вижу, что смена
itemsвсё равно вызывает ререндер, но на такие вещи полагаться нельзя. Ни в коем случае. Минимальный рефакторинг в будущем, когда человек не будет иметь всей картинки костылей в голове, легко поломает этот "код". И да, текущее "случайно" работающее поведение это как раз костыль. Красный флаг.Вот чтобы таких вещей не было надо вникать в то как хуки работают, какие задачи они выполняют, и каков вообще hook way в реакте. Судя по всему (по твоим ответам и коду в статье) ты пока пишешь "на ощупь". Отсюда и типовые ошибки и типовые костыли. Серьёзно, я не хочу обидеть, просто это видно издалека.
Вот это тоже грубая ошибка. Которую, насколько я понял, ты уже усвоил. Тут действует простое правило — всё что можно посчитать на основе уже существующих данных — не нужно хранить в стейте. Максимум мемоизировать (
useMemo), если вычисления тяжёлые. Причина банальна — ручная синхронизация = новый источник багов = дорого. В твоём случае ещё и rerender-ы.За что боролся на то и напоролся. Когда пишешь core-вещи, т.е. обобщённый многократно переиспользуемый код (а твой хук как раз из таких), то это должна быть вылизанная до мелочей оптимизированная штука. Иначе — руки прочь из core части. Даже вопроса такого не должно возникать.
А где я сказал "всегда"? У нас на 80к строк кода всего несколько
useReducer. У тебя как раз такой случай, когдаuseReducerупрощает понимание кода, убирает лишние рендеры, легко scale-уется в случае сложных доработок. То, что доктор прописал. Да ещё и все переменные тесно связанные между собой. Особенно если учесть что в настоящем боевом коде этот хук будет куда сложнее, когда полезут corner case-ы.Не возможно, а точно, я тебе говорю. На этапе system design такой ответ это красный флаг и "мы вам перезвоним". Тебе завтра потребуется подключить этот хук в другую часть приложения где более сложная работа с URL (или просто другая) и тебе придётся выпиливать всё до последней буквы. Да даже просто наличие на странице сразу двух постраничных виджетов (или списка списокв) и "приехали".
А причина банальная — это не задача для хука который занимается вызовом асинхр. метода который подтягивает данные согласно постраничной навигации. "low in coupling and high in cohesion" — вот главная мантра любой архитектуры. Из неё автоматически вытекает что не должно быть таких пучков которые умеют во всё сразу, особенно как-то конкретно (
?page=в любой URL игнорируя рутинг приложения).beDenz
Что вы имеете ввиду под "курсором"?
faiwer
Когда вместо
SELECT ... OFFSET {(page - 1) * limit} LIMIT {limit}используются более сложные схемы. Например берётсяitems.last().createdBy.toUnixTime()и возвращается в качестве курсора\якоря\как-угодно-можно-назвать. А на сервереWHERE createdBy >= {cursor}.Это не даёт вам общего числа "страниц", зато сильно улучшает выборку данных, когда просматриваемый список элементов не статичен. Из-за постраничной навигации вы получаете дублирующие элементы или вообще теряете часть. Просто потому что между кликаниями по страницам кто-то меняет выборку.
aio350 Автор
Спасибо за ревью, друг.
1. Я бы не сказал, что "хранить все в одном объекте" всегда проще.
2. По поводу гонки условий ты (ничего, что я на ты?) прав. Добавил парочку условий.
3. Согласен, но это всего лишь пример хука, а не полноценное приложение. Думаю, что возвращение ошибки лучше оставить `fetchItems`.
4. Согласен, мне просто хотелось показать, как это можно сделать. Если в качестве `fetchItems` использовать `swr`, например, то за кеширование будет отвечать хук `useSWR`.
5. Возможно.
6. Нет смысла, оптимизация будет преждевременной.