
Привет, друзья!
В этой статье я хочу поделиться с вами опытом разработки хука для загрузки дополнительных данных (авось кому-нибудь пригодится).
На самом деле, хуков будет целых 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-productsProductsByPage.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 также реализовано переключение между страницами товаров при нажатии стрелок на клавиатуре.
Пожалуй, это все, чем я хотел поделиться с вами в данной статье.
Буду рад любой форме обратной связи.
Благодарю за внимание и хорошего дня!
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩

