🔶 6. Работа с изображениями
Добавление обложки
Скачайте картинку по умолчанию (можно использовать свое изображение) и положите ее в директорию (assets/images/)
Чтобы скачать, нажмите на изображение default-playlist-cover
originalCover
Отобразим обложку в плейлисте
import defaultCover from '@/assets/images/default-playlist-cover.png'
import s from './PlaylistItem.module.css'
type Props = {
playlist : PlaylistData
deletePlaylist : (playlistId : string ) => void
editPlaylist : (playlist : PlaylistData ) => void
}
export const PlaylistItem = ({ playlist , editPlaylist , deletePlaylist } : Props ) => {
const originalCover = playlist . attributes . images . main ?.find (img => img .type === 'original' )
const src = originalCover ? originalCover ?.url : defaultCover
return (
< div >
< img src = {src} alt = { 'cover' } width = { '100px' } className = { s .cover} />
< div >title: { playlist . attributes .title}</ div >
{ /*...*/ }
</ div >
)
}
.cover {
width : 240 px ;
height : 240 px ;
object-fit : cover ;
}
useUploadPlaylistCoverMutation
Реализуем логику загрузки изображения плейлиста, согласно документации
export const playlistsApi = baseApi .injectEndpoints ({
endpoints : build => ({
/*...*/
uploadPlaylistCover : build .mutation < Images , { playlistId : string ; file : File }>({
query : ({ playlistId , file }) => {
const formData = new FormData ()
formData .append ( 'file' , file)
return {
url : `playlists/ ${ playlistId } /images/main` ,
method : 'post' ,
body : formData ,
}
} ,
invalidatesTags : [ 'Playlist' ] ,
}) ,
}) ,
})
Логика загрузки изображения
export const PlaylistItem = ({ playlist , editPlaylist , deletePlaylist } : Props ) => {
const originalCover = playlist . attributes . images . main ?.find (img => img .type === 'original' )
const src = originalCover ? originalCover ?.url : defaultCover
const [ uploadCover ] = useUploadPlaylistCoverMutation ()
const uploadCoverHandler = (event : ChangeEvent < HTMLInputElement >) => {
const maxSize = 1024 * 1024 // 1 MB
const allowedTypes = [ 'image/jpeg' , 'image/png' , 'image/gif' ]
const file = event . target . files ?. length && event . target .files[ 0 ]
if ( ! file) return
if ( ! allowedTypes .includes ( file .type)) {
alert ( 'Only JPEG, PNG or GIF images are allowed' )
return
}
if ( file .size > maxSize) {
alert ( `The file is too large. Max size is ${ Math .round (maxSize / 1024 ) } KB` )
return
}
uploadCover ({ playlistId : playlist .id , file })
}
return (
< div >
< img src = {src} alt = { 'cover' } width = { '100px' } className = { s .cover} />
< input type = "file" accept = "image/jpeg,image/png,image/gif" onChange = {uploadCoverHandler} />
< div >title: { playlist . attributes .title}</ div >
{ /*...*/ }
</ div >
)
}
Результат: обложка успешно загружается и отображается 🚀
Удаление обложки
Реализуем логику удаления обложки, согласно документации
export const playlistsApi = baseApi .injectEndpoints ({
endpoints : build => ({
/*...*/
deletePlaylistCover : build .mutation < void , { playlistId : string }>({
query : ({ playlistId }) => ({ url : `playlists/ ${ playlistId } /images/main` , method : 'delete' }) ,
invalidatesTags : [ 'Playlist' ] ,
}) ,
}) ,
})
export const PlaylistItem = ({ playlist , editPlaylist , deletePlaylist } : Props ) => {
const originalCover = playlist . attributes . images . main ?.find (img => img .type === 'original' )
const src = originalCover ? originalCover ?.url : defaultCover
const [ uploadCover ] = useUploadPlaylistCoverMutation ()
const [ deleteCover ] = useDeletePlaylistCoverMutation ()
const uploadCoverHandler = (event : ChangeEvent < HTMLInputElement >) => {
/*...*/
}
const deleteCoverHandler = () => {
deleteCover ({ playlistId : playlist .id })
}
return (
< div >
< img src = {src} alt = { 'cover' } width = { '100px' } className = { s .cover} />
< input type = "file" accept = "image/jpeg,image/png,image/gif" onChange = {uploadCoverHandler} />
{originalCover && < button onClick = {() => deleteCoverHandler ()}>delete cover</ button >}
{ /*...*/ }
</ div >
)
}
Результат: обложка удаляется 🚀
Рефакторинг
Декомпозиция
type Props = {
playlistId : string
images : Images
}
export const PlaylistCover = ({ images , playlistId } : Props ) => {
const originalCover = images . main ?.find (img => img .type === 'original' )
const src = originalCover ? originalCover ?.url : defaultCover
const [ uploadCover ] = useUploadPlaylistCoverMutation ()
const [ deleteCover ] = useDeletePlaylistCoverMutation ()
const uploadCoverHandler = (event : ChangeEvent < HTMLInputElement >) => {
const maxSize = 1024 * 1024 // 1 MB
const allowedTypes = [ 'image/jpeg' , 'image/png' , 'image/gif' ]
const file = event . target . files ?. length && event . target .files[ 0 ]
if ( ! file) return
if ( ! allowedTypes .includes ( file .type)) {
alert ( 'Only JPEG, PNG or GIF images are allowed' )
}
if ( file .size > maxSize) {
alert ( `The file is too large. Max size is ${ Math .round (maxSize / 1024 ) } KB` )
}
uploadCover ({ playlistId , file })
}
const deleteCoverHandler = () => deleteCover ({ playlistId })
return (
< div >
< img src = {src} alt = { 'cover' } width = { '100px' } className = { s .cover} />
< input type = "file" accept = "image/jpeg,image/png,image/gif" onChange = {uploadCoverHandler} />
{originalCover && < button onClick = {() => deleteCoverHandler ()}>delete cover</ button >}
</ div >
)
}
⚠️
Перенесите стили для обложки из PlaylistItem.module.css в PlaylistCover.module.css
type Props = {
attributes : PlaylistAttributes
}
export const PlaylistDescription = ({ attributes } : Props ) => {
return (
<>
< div >title: { attributes .title}</ div >
< div >description: { attributes .description}</ div >
< div >userName: { attributes . user .name}</ div >
</>
)
}
type Props = {
playlist : PlaylistData
deletePlaylist : (playlistId : string ) => void
editPlaylist : (playlist : PlaylistData ) => void
}
export const PlaylistItem = ({ playlist , editPlaylist , deletePlaylist } : Props ) => {
return (
< div >
< PlaylistCover playlistId = { playlist .id} images = { playlist . attributes .images} />
< PlaylistDescription attributes = { playlist .attributes} />
< button onClick = {() => deletePlaylist ( playlist .id)}>delete</ button >
< button onClick = {() => editPlaylist (playlist)}>update</ button >
</ div >
)
}
Результат: рефакторинг завершен. Код работает 🚀
React toastify
Заменим alert на красивое уведомление
Установите библиотеку для работы с уведомлениями react-toastify :
import { ToastContainer } from 'react-toastify'
export const App = () => {
return (
<>
< Header />
< div className = { s .layout}>
< Routing />
</ div >
< ToastContainer />
</>
)
}
export const PlaylistCover = ({ images , playlistId } : Props ) => {
/*...*/
const uploadCoverHandler = (event : ChangeEvent < HTMLInputElement >) => {
/*...*/
if ( ! allowedTypes .includes ( file .type)) {
toast ( 'Only JPEG, PNG or GIF images are allowed' , { type : 'error' , theme : 'colored' })
}
if ( file .size > maxSize) {
toast ( `The file is too large (max. ${ Math .round (maxSize / 1024 ) } KB)` , {
type : 'error' ,
theme : 'colored' ,
})
}
uploadCover ({ playlistId , file })
}
/*...*/
}
Результат: получаем красивое всплывающее уведомление 🚀
🔶 7. Поиск, пагинация
Давайте вернемся к запросу за плейлистами и обратим внимание на большое количество query параметров, которые необходимы,
чтобы реализовать:
поиск по названию плейлиста (search)
пагинацию (pageNumber, pageSize)
различные сортировки (sortBy, sortDirection)
фильтрацию по tagsIds - получение плейлистов по тегам
фильтрацию по userId - получение только своих плейлистов
фильтрацию по trackId – в результатах только плейлисты с этим треком
Некоторый функционал мы реализуем, а некоторый останется вам в качестве домашнего задания.
Поиск
Api
export const playlistsApi = baseApi .injectEndpoints ({
endpoints : build => ({
fetchPlaylists : build .query < PlaylistsResponse , FetchPlaylistsArgs >({
query : params => ({ url : `playlists` , params }) ,
providesTags : [ 'Playlist' ] ,
}) ,
/*...*/
}) ,
})
UI
⚠️
Если плейлисты не найдены, то необходимо показать это пользователю
export const PlaylistsPage = () => {
const [ playlistId , setPlaylistId ] = useState < string | null >( null )
const [ search , setSearch ] = useState ( '' )
const { register , handleSubmit , reset } = useForm < UpdatePlaylistArgs >()
const { data , isLoading } = useFetchPlaylistsQuery ({ search })
/*...*/
return (
< div className = { s .container}>
< h1 >Playlists page</ h1 >
< CreatePlaylistForm />
< input
type = "search"
placeholder = { 'Search playlist by title' }
onChange = {e => setSearch ( e . currentTarget .value)}
/>
< div className = { s .items}>
{ ! data ?. data . length && ! isLoading && < h2 >Playlists not found</ h2 >}
{ /*...*/ }
</ div >
</ div >
)
}
Результат: поиск работает🚀
Debounce
Обратите внимание, что при каждом вводе символа у нас уходит новый запрос. В нашем случае это не самое лучшее поведение.
Давайте реализуем debounce
Debounce — это техника в программировании, которая ограничивает частоту вызовов функции, чтобы она выполнялась
только после того, как прошло определенное время без новых вызовов.
Как это работает?
Пользователь совершает действие (например, ввод текста в поисковой строке).
Вместо немедленного выполнения функции (например, отправки запроса) запускается таймер.
Если действие повторяется до истечения таймера, предыдущий вызов отменяется, и таймер сбрасывается.
Функция выполняется только после того, как таймер полностью истек (т. е. новых вызовов не было).
Реализация
Существует множество способов реализации debounce.
Например, можно воспользоваться готовым универсальным решением useDebounceValue , а можно написать свою
упрощенную версию
export const useDebounceValue = < T >(value : T , delay : number = 700 ) : T => {
const [ debounced , setDebounced ] = useState (value)
useEffect (() => {
const handler = setTimeout (() => setDebounced (value) , delay)
return () => clearTimeout (handler)
} , [value , delay])
return debounced
}
export const PlaylistsPage = () => {
/*...*/
const [ search , setSearch ] = useState ( '' )
const debounceSearch = useDebounceValue (search)
const { data , isLoading } = useFetchPlaylistsQuery ({ search : debounceSearch })
/*...*/
}
Результат: поиск работает с debounce🚀
Пагинация
Теория
Пагинация — это разбиение большого списка данных на отдельные страницы для удобства просмотра и навигации.
Существует множество способов как реализовать пагинацию.
Есть множество готовых решений:
Но мы сделаем свою собственную пагинацию. Вы можете взять код из методички, вставить к себе в проект и настроить под себя
бэкенд возвращает метаинформацию на основании которой, мы сможем построить пагинацию
export type PlaylistMeta = {
page : number
pageSize : number
totalCount : number
pagesCount : number
}
export const PlaylistsPage = () => {
/*...*/
const [ currentPage , setCurrentPage ] = useState ( 1 )
const { data , isLoading } = useFetchPlaylistsQuery ({
search : debounceSearch ,
pageNumber : currentPage ,
pageSize : 2 ,
})
/*...*/
return (
< div className = { s .container}>
{ /*...*/ }
< Pagination
currentPage = {currentPage}
setCurrentPage = {setCurrentPage}
pagesCount = { data ?. meta .pagesCount || 1 }
/>
</ div >
)
}
common/components/Pagination/Pagination.tsx
import { getPaginationPages } from '@/common/utils'
import s from './Pagination.module.css'
type Props = {
currentPage : number
setCurrentPage : (page : number ) => void
pagesCount : number
}
export const Pagination = ({ currentPage , setCurrentPage , pagesCount } : Props ) => {
if (pagesCount <= 1 ) return null
const pages = getPaginationPages (currentPage , pagesCount)
return (
< div className = { s .pagination}>
{ pages .map ((page , idx) =>
page === '...' ? (
< span className = { s .ellipsis} key = { `ellipsis- ${ idx } ` }>
...
</ span >
) : (
< button
key = {page}
className = {
page === currentPage ? ` ${ s .pageButton } ${ s .pageButtonActive } ` : s .pageButton
}
onClick = {() => page !== currentPage && setCurrentPage ( Number (page))}
disabled = {page === currentPage}
type = "button"
>
{page}
</ button >
)
)}
</ div >
)
}
.pagination {
display : flex ;
gap : 8 px ;
justify-content : center ;
margin-top : 24 px ;
}
.pageButton {
padding : 4 px 10 px ;
background : white ;
border : 1 px solid #aaa ;
border-radius : 4 px ;
cursor : pointer ;
}
.pageButtonActive {
background : #ececec ;
cursor : default ;
}
.ellipsis {
padding : 4 px 10 px ;
color : #888 ;
user-select : none ;
}
common/utils/getPaginationPages.ts
const SIBLING_COUNT = 1
/**
* Генерирует массив страниц для отображения пагинации с многоточиями
*/
export const getPaginationPages = (currentPage : number , pagesCount : number ) : ( number | '...' )[] => {
if (pagesCount <= 1 ) return []
const pages : ( number | '...' )[] = []
// Границы диапазона вокруг текущей страницы
const leftSibling = Math .max ( 2 , currentPage - SIBLING_COUNT )
const rightSibling = Math .min (pagesCount - 1 , currentPage + SIBLING_COUNT )
// Всегда показываем первую страницу
pages .push ( 1 )
// Многоточие слева
if (leftSibling > 2 ) {
pages .push ( '...' )
}
// Соседние страницы вокруг текущей страницы
for ( let page = leftSibling; page <= rightSibling; page ++ ) {
pages .push (page)
}
// Многоточие справа
if (rightSibling < pagesCount - 1 ) {
pages .push ( '...' )
}
// Всегда показываем последнюю страницу (если их больше одной)
if (pagesCount > 1 ) {
pages .push (pagesCount)
}
return pages
}
Результат: пагинация готова 🚀
pageSize
Усложним пагинацию и дадим пользователю возможность менять количество плейлистов на странице (pageSize)
export const PlaylistsPage = () => {
/*...*/
const [ currentPage , setCurrentPage ] = useState ( 1 )
const [ pageSize , setPageSize ] = useState ( 2 )
/*...*/
const { data , isLoading } = useFetchPlaylistsQuery ({
search : debounceSearch ,
pageNumber : currentPage ,
pageSize ,
})
/*...*/
const changePageSizeHandler = (size : number ) => {
setPageSize (size)
setCurrentPage ( 1 )
}
return (
< div className = { s .container}>
{ /*...*/ }
< Pagination
currentPage = {currentPage}
setCurrentPage = {setCurrentPage}
pagesCount = { data ?. meta .pagesCount || 1 }
pageSize = {pageSize}
changePageSize = {changePageSizeHandler}
/>
</ div >
)
}
type Props = {
/*...*/
pageSize : number
changePageSize : (size : number ) => void
}
export const Pagination = ({
currentPage ,
setCurrentPage ,
pagesCount ,
pageSize ,
changePageSize ,
} : Props ) => {
if (pagesCount <= 1 ) return null
const pages = getPaginationPages (currentPage , pagesCount)
return (
< div className = { s .container}>
{ /*...*/ }
< label >
Show
< select value = {pageSize} onChange = {e => changePageSize ( Number ( e . target .value))}>
{[ 2 , 4 , 8 , 16 , 32 ] .map (size => (
< option value = {size} key = {size}>
{size}
</ option >
))}
</ select >
per page
</ label >
</ div >
)
}
.container {
display : flex ;
align-content : center ;
align-items : center ;
margin : 0 auto ;
gap : 40 px ;
}
.pagination {
display : flex ;
gap : 8 px ;
justify-content : center ;
/*margin-top: 24px; ❌ remove */
}
Результат: внедрили в пагинацию возможность менять кол-во страниц 🚀
Рефакторинг
Бага в поиске
Воспрозведение баги:
на первой странице найдите плейлист, который хотите найти. Например, с названием cat
смените страницу на любую другую и попробуйте найти данный плейлист. Вы его не найдете, т.к. pageNumber изменился
чтобы решить эту проблему, необходимо при поиске всегда сбрасывать pageNumber на первую страницу
export const PlaylistsPage = () => {
/*...*/
const searchPlaylistHandler = (e : ChangeEvent < HTMLInputElement >) => {
setSearch ( e . currentTarget .value)
setCurrentPage ( 1 )
}
return (
< div className = { s .container}>
< h1 >Playlists page</ h1 >
< CreatePlaylistForm />
< input
type = "search"
placeholder = { 'Search playlist by title' }
onChange = { searchPlaylistHandler }
/>
{ /*...*/ }
</ div >
)
}
Результат: пофиксили багу 🚀
Декомпозиция
Компонент PlaylistsPage.tsx стал большим и довольно сложно читаемым, поэтому прежде чем двигаться дальше
декомпозируем его
export const PlaylistsPage = () => {
const [ currentPage , setCurrentPage ] = useState ( 1 )
const [ pageSize , setPageSize ] = useState ( 2 )
const [ search , setSearch ] = useState ( '' )
const debounceSearch = useDebounceValue (search)
const { data , isLoading } = useFetchPlaylistsQuery ({
search : debounceSearch ,
pageNumber : currentPage ,
pageSize ,
})
const changePageSizeHandler = (size : number ) => {
setPageSize (size)
setCurrentPage ( 1 )
}
const searchPlaylistHandler = (e : ChangeEvent < HTMLInputElement >) => {
setSearch ( e . currentTarget .value)
setCurrentPage ( 1 )
}
return (
< div className = { s .container}>
< h1 >Playlists page</ h1 >
< CreatePlaylistForm />
< input
type = "search"
placeholder = { 'Search playlist by title' }
onChange = {searchPlaylistHandler}
/>
< PlaylistsList playlists = { data ?.data || []} isPlaylistsLoading = {isLoading} />
< Pagination
currentPage = {currentPage}
setCurrentPage = {setCurrentPage}
pagesCount = { data ?. meta .pagesCount || 1 }
pageSize = {pageSize}
changePageSize = {changePageSizeHandler}
/>
</ div >
)
}
type Props = {
playlists : PlaylistData []
isPlaylistsLoading : boolean
}
export const PlaylistsList = ({ playlists , isPlaylistsLoading } : Props ) => {
const [ playlistId , setPlaylistId ] = useState < string | null >( null )
const { register , handleSubmit , reset } = useForm < UpdatePlaylistArgs >()
const [ deletePlaylist ] = useDeletePlaylistMutation ()
const deletePlaylistHandler = (playlistId : string ) => {
if ( confirm ( 'Are you sure you want to delete the playlist?' )) {
deletePlaylist (playlistId)
}
}
const editPlaylistHandler = (playlist : PlaylistData | null ) => {
if (playlist) {
setPlaylistId ( playlist .id)
reset ({
title : playlist . attributes .title ,
description : playlist . attributes .description ,
tagIds : playlist . attributes . tags .map (t => t .id) ,
})
} else {
setPlaylistId ( null )
}
}
return (
< div className = { s .items}>
{ ! playlists . length && ! isPlaylistsLoading && < h2 >Playlists not found</ h2 >}
{ playlists .map (playlist => {
const isEditing = playlistId === playlist .id
return (
< div className = { s .item} key = { playlist .id}>
{isEditing ? (
< EditPlaylistForm
playlistId = {playlistId}
handleSubmit = {handleSubmit}
register = {register}
editPlaylist = {editPlaylistHandler}
setPlaylistId = {setPlaylistId}
/>
) : (
< PlaylistItem
playlist = {playlist}
deletePlaylist = {deletePlaylistHandler}
editPlaylist = {editPlaylistHandler}
/>
)}
</ div >
)
})}
</ div >
)
}
Перенесите items и item из PlaylistsPage.module.css
.items {
display : flex ;
gap : 30 px ;
flex-wrap : wrap ;
}
.item {
width : 240 px ;
padding : 16 px ;
border : 1 px solid #ddd ;
border-radius : 8 px ;
}
Результат: рефакторинг завершен. Код работает 🚀
Задание для закрепления материала
🏠 В качестве закрепления можете самостоятельно декомпозировать Pagination.tsx на PaginationControls.tsx и
PageSizeSelector.tsx
🔶 8. Cache
Кэширование
Откройте network и посмотрите на запросы при переходе на разные страницы. Запросы идут,
если страница открывается впервые. Но при открытии страницы повторно запрос не отправляется,
потому что данные берутся из кэша.
KeepUnusedDataFor
Время хранения данных в кэше, по умолчанию, составляет 60 секунд. Но это время можно настраивать с помощью
keepUnusedDataFor в baseApi.ts:
export const baseApi = createApi ({
reducerPath : 'baseApi' ,
tagTypes : [ 'Playlist' ] ,
keepUnusedDataFor : 5 ,
/*...*/
})
Также keepUnusedDataFor можно задавать для конкретного query-запроса.
RefetchOnFocus
В RTK Query refetchOnFocus используется для автоматического повторного запроса за данными,
когда окно приложения или вкладка браузера попадают в фокус.
Пример работы:
когда пользователь уходит с вкладки, запрос данных не выполняется;
когда пользователь возвращается на вкладку (т.е. окно браузера или приложение попадает в фокус),
данные автоматически обновляются через запрос на сервер:
export const baseApi = createApi ({
reducerPath : 'baseApi' ,
tagTypes : [ 'Playlist' ] ,
refetchOnFocus : true ,
/*...*/
})
Результат: откройте две вкладки с приложением. Создайте плейлист в одной вкладке, затем
перейдите на другую. Там уже подгрузится новый плейлист без перезагрузки страницы 🚀
RefetchOnReconnect
refetchOnReconnect
управляет повторным запросом данных, когда приложение или браузер восстанавливает соединение с интернетом после
его потери.
Когда это полезно:
приложение работает в условиях нестабильного соединения, и нужно гарантировать, что данные всегда актуальны после
переподключения;
когда в приложении есть данные, которые могут изменяться, пока пользователь был офлайн (например, в социальных сетях,
потоковых данных или финансовых приложениях):
export const baseApi = createApi ({
reducerPath : 'baseApi' ,
tagTypes : [ 'Playlist' ] ,
refetchOnReconnect : true ,
/*...*/
})
Результат: откройте две вкладки с приложением. На одной вкладке выключите network, а
на другой вкладке измените название плейлиста. Затем вернитесь на первую вкладку и включите
network. Там уже отобразилось новое название плейлиста без перезагрузки страницы 🚀
Polling
Polling позволяет автоматически
повторять запросы через определённые интервалы времени для поддержания актуальности данных.
Это полезно для приложений, которые отображают динамические данные:
Мониторинг статуса заказа (e‑commerce, служба доставки) : Cервер меняет статус нерегулярно, но редко -
держать постоянное соединение нет смысла; обновлять раз в 5–15 сек вполне достаточно.
Котировки криптовалют/акций : Большинство публичных REST‑API не поддерживают WebSocket бесплатно; опрос раз в 5–30
сек — компромисс между свежестью и лимитом запросов.
Дашборд админа / health‑check микросервисов : REST‑энд‑поинт уже возвращает сжатый summary‑JSON; realtime не
критичен, важна простота.
Пример работы:
Когда включен polling, RTK Query автоматически повторяет запросы через заданный интервал,
обновляя данные на клиенте. RTK Query оптимизирует процесс, останавливая запрос, если
компонент размонтирован или пользователь не взаимодействует с приложением:
const { data , isLoading } = useFetchPlaylistsQuery (
{
search : debounceSearch ,
pageNumber : currentPage ,
pageSize ,
} ,
{
pollingInterval : 3000 ,
skipPollingIfUnfocused : true ,
}
)
Результат: запрос за плейлистами идет каждые три секунды 🚀
Получение данных на другой странице
Например, нам необходимо получить информацию о себе на разных страницах
RTK slice
Если бы мы использовали RTK со слайсами, то мы бы реализовали это так:
сделали запрос на сервер в App.tsx
полученные данные мы сохранили бы в стейте authSlice.ts
при помощи useSelector мы достали бы данные на любой странице
Но как получать данные на другой странице при использовании RTK query 🤔 ?
RTK query
Реализуем страницу профиля (ProfilePage.tsx), на которой выведем имя пользователя, а в дальнейшем будем
отображать плейлисты и треки созданные пользователем.
На главной странице (MainPage.tsx) тоже выведем информацию о пользователе.
src/features/auth/api/authApi.ts
export const authApi = baseApi .injectEndpoints ({
endpoints : build => ({
getMe : build .query < MeResponse , void >({
query : () => `auth/me` ,
}) ,
}) ,
})
export const { useGetMeQuery } = authApi
src/features/auth/api/authApi.types.ts
export type MeResponse = {
userId : string
login : string
}
export const ProfilePage = () => {
const { data } = useGetMeQuery ()
return < h1 >{ data ?.login} page</ h1 >
}
export const MainPage = () => {
const { data } = useGetMeQuery ()
return (
< div >
< h1 >Main page</ h1 >
< div >login: { data ?.login} </ div >
</ div >
)
}
Обратите внимание, что в компонентах MainPage.tsx и ProfilePage.tsx мы сделали вызов одного и того же хука
const { data } = useGetMeQuery(). На первый взгляд может показаться, что при таком подходе переходя на другую страницу
мы будем делать новый запрос.
Однако при первом запросе данные кэшеруются и когда мы идем на другую страницу, то данные достаем уже из кэша 💪
Результат: таким образом мы и получаем данные на разных страницах без необходимости делать повторные запросы и
сохранять данные в глобальном стейте 🚀
🔶 9. Infinity Queries
Реализуем бесконечный скролл на странице с треками
infiniteQuery подходит для следующих задач:
Бесконечной прокрутки (infinite scroll)
Кнопки "Загрузить еще"
Ленты новостей/постов
Когда нужно показать все данные в одном списке
Cursor paginate
Курсорная пагинация -- это способ разбивки данных на страницы с помощью уникального указателя (курсора)
вместо номеров страниц.
Принцип:
Курсор -- это ID последнего элемента на текущей странице
Следующая страница начинается после этого курсора
Нет пропусков - даже если добавляются новые записи
Преимущества:
Стабильность -- нет дублирования при добавлении новых записей
Производительность -- быстрее для больших объемов данных
Реальное время -- работает с постоянно обновляющимися данными
Недостатки:
Нельзя прыгать на произвольную страницу
Только вперед - обычно нет возможности вернуться назад
Идеально для : лент новостей, чатов, бесконечной прокрутки.
api
features/tracks/api/tracksApi.ts
features/tracks/api/tracksApi.ts
export const tracksApi = baseApi .injectEndpoints ({
endpoints : build => ({
fetchTracks : build .infiniteQuery < FetchTracksResponse , void , string | undefined >({
infiniteQueryOptions : {
initialPageParam : undefined ,
getNextPageParam : lastPage => {
return lastPage . meta .nextCursor || undefined
} ,
} ,
query : ({ pageParam }) => {
return {
url : 'playlists/tracks' ,
params : { cursor : pageParam , pageSize : 5 , paginationType : 'cursor' } ,
}
} ,
}) ,
}) ,
})
export const { useFetchTracksInfiniteQuery } = tracksApi
features/tracks/api/tracksApi.types.ts
features/tracks/api/tracksApi.types.ts
import type { CurrentUserReaction } from '@/common/enums'
import type { Images , User } from '@/common/types'
export type FetchTracksResponse = {
data : TrackData []
included : TracksIncluded []
meta : TracksMeta
}
export type TrackData = {
id : string
type : 'tracks'
attributes : TrackAttributes
relationships : TrackRelationships
}
export type TracksIncluded = {
id : string
type : 'artists'
attributes : {
name : string
}
}
export type TracksMeta = {
nextCursor : string | null
page : number
pageSize : number
totalCount : number | null
pagesCount : number | null
}
export type TrackAttributes = {
title : string
addedAt : string
attachments : TrackAttachment []
images : Images
currentUserReaction : CurrentUserReaction
user : User
isPublished : boolean
publishedAt : string
}
export type TrackRelationships = {
artists : {
data : {
id : string
type : string
}
}
}
export type TrackAttachment = {
id : string
addedAt : string
updatedAt : string
version : number
url : string
contentType : string
originalName : string
fileSize : number
}
// Arguments
export type FetchTracksArgs = {
pageNumber ?: number
pageSize ?: number
search ?: string
sortBy ?: 'publishedAt' | 'likesCount'
sortDirection ?: 'asc' | 'desc'
tagsIds ?: string []
artistsIds ?: string []
userId ?: string
includeDrafts ?: boolean
paginationType ?: 'offset' | 'cursor'
cursor ?: string
}
ui
Вызовем хук useFetchTracksInfiniteQuery и посмотрим на полученный результат
export const TracksPage = () => {
const { data } = useFetchTracksInfiniteQuery ()
console .log (data)
return (
< div >
< h1 >Tracks page</ h1 >
</ div >
)
}
Обратите внимание, что хук вернул нам структуру {pages: DataType[], pageParams: PageParam[]},
которая содержит все полученные результаты страниц и соответствующие параметры страниц, использованные для их загрузки.
Эта структура обеспечивает гибкость в отображении данных в вашем интерфейсе (показывать отдельные страницы или
объединять в единый список), позволяет ограничивать количество страниц, хранящихся в кэше, а также даёт возможность
динамически определять следующую или предыдущую страницу для загрузки — на основе самих данных или их параметров.
Отрисуем данные
💡
Чтобы уменьшить вложенность воспользуемся flat или flatMap
import { useFetchTracksInfiniteQuery } from '../../api/tracksApi.ts'
import s from './TracksPage.module.css'
export const TracksPage = () => {
const { data } = useFetchTracksInfiniteQuery ({ paginationType : 'cursor' , pageSize : 5 })
const pages = data ?. pages .map (page => page .data) .flat () || []
// const pages = data?.pages.flatMap((page) => page.data) || []
return (
< div >
< h1 >Tracks page</ h1 >
< div className = { s .list}>
{ pages .map (track => {
const { title , user , attachments } = track .attributes
return (
< div key = { track .id} className = { s .item}>
< div >
< p >Title: {title}</ p >
< p >Name: { user .name}</ p >
</ div >
{ attachments . length ? < audio controls src = {attachments[ 0 ].url} /> : 'no file' }
</ div >
)
})}
</ div >
</ div >
)
}
.list {
display : flex ;
flex-direction : column ;
gap : 20 px ;
}
.item {
display : flex ;
justify-content : space-between ;
align-items : center ;
padding : 16 px ;
border : 1 px solid #ddd ;
border-radius : 8 px ;
}
Результат: отрисовали первую страницу 🚀
Load more
export const TracksPage = () => {
const { data , isLoading , isFetching , isFetchingNextPage , fetchNextPage , hasNextPage } =
useFetchTracksInfiniteQuery ()
const pages = data ?. pages .flatMap (page => page .data) || []
const loadMoreHandler = () => {
if (hasNextPage && ! isFetching) {
fetchNextPage ()
}
}
return (
< div >
{ /*...*/ }
{ ! isLoading && (
<>
{hasNextPage ? (
< button onClick = {loadMoreHandler} disabled = {isFetching}>
{isFetchingNextPage ? 'Loading...' : 'Load More' }
</ button >
) : (
< p >Nothing more to load</ p >
)}
</>
)}
</ div >
)
}
Результат: при нажатии на кнопку load more треки подгружаются до тех пор, пока полностью не подгрузятся 🚀
Мы хотим, чтобы при прокрутке вниз треки автоматически добавлялись. Чтобы реализовать данную задачу, нам необходимо
отслеживать момент когда мы домотали до конца страницы и делать сразу же запрос.
Для реализации данной задачи можно использовать библиотеку react-intersection-observer
Но можно обойтись без библиотеки и написать самостоятельно.
export const TracksPage = () => {
const { data , isFetching , isFetchingNextPage , fetchNextPage , hasNextPage } =
useFetchTracksInfiniteQuery ()
// Создает ссылку на DOM элемент, который будет "триггером" для автозагрузки
const observerRef = useRef < HTMLDivElement >( null )
const pages = data ?. pages .flatMap (page => page .data) || []
const loadMoreHandler = useCallback (() => {
if (hasNextPage && ! isFetching) {
fetchNextPage ()
}
} , [hasNextPage , isFetching , fetchNextPage])
useEffect (() => {
// IntersectionObserver отслеживает элементы и сообщает, насколько они видны во viewport
// https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
const observer = new IntersectionObserver (
entries => {
// entries - наблюдаемый элемент
if ( entries . length > 0 && entries[ 0 ].isIntersecting) {
loadMoreHandler ()
}
} ,
{
root : null , // Отслеживание относительно окна браузера (viewport). null = весь экран
rootMargin : '100px' , // Начинать загрузку за 100px до появления элемента
threshold : 0.1 , // Срабатывать когда 10% элемента становится видимым
}
)
const currentObserverRef = observerRef .current
if (currentObserverRef) {
// начинает наблюдение за элементом
observer .observe (currentObserverRef)
}
// Функция очистки - прекращает наблюдение при размонтировании компонента
return () => {
if (currentObserverRef) {
observer .unobserve (currentObserverRef)
}
}
} , [loadMoreHandler])
return (
< div >
< h1 >Tracks page</ h1 >
< div className = { s .list}>
{ pages .map (track => {
const { title , user , attachments } = track .attributes
return (
< div key = { track .id} className = { s .item}>
< div >
< p >Title: {title}</ p >
< p >Name: { user .name}</ p >
</ div >
{ attachments . length ? < audio controls src = {attachments[ 0 ].url} /> : 'no file' }
</ div >
)
})}
</ div >
{hasNextPage && (
// Этот элемент отслеживается IntersectionObserver
< div ref = {observerRef}>
{ /*`<div style={{ height: '20px' }} />` создает "невидимую зону" в 20px в конце списка,*/ }
{ /*при достижении которой автоматически загружаются новые треки. Без размеров*/ }
{ /*IntersectionObserver не будет работать корректно.*/ }
{isFetchingNextPage ? (
< div >Loading more tracks...</ div >
) : (
< div style = {{ height : '20px' }} />
)}
</ div >
)}
{ ! hasNextPage && pages . length > 0 && < p >Nothing more to load</ p >}
</ div >
)
}
Результат: infinity scroll реализован 🚀
Декомпозиция
common/hooks/useInfiniteScroll.ts
import { useCallback , useEffect , useRef } from 'react'
type Props = {
hasNextPage : boolean
isFetching : boolean
fetchNextPage : () => void
rootMargin ?: string
threshold ?: number
}
export const useInfiniteScroll = ({
hasNextPage ,
isFetching ,
fetchNextPage ,
rootMargin = '100px' ,
threshold = 0.1 ,
} : Props ) => {
const observerRef = useRef < HTMLDivElement >( null )
const loadMoreHandler = useCallback (() => {
if (hasNextPage && ! isFetching) {
fetchNextPage ()
}
} , [hasNextPage , isFetching , fetchNextPage])
useEffect (() => {
// IntersectionObserver отслеживает элементы и сообщает, насколько они видны во viewport
// https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
const observer = new IntersectionObserver (
entries => {
// entries - наблюдаемый элемент
if ( entries . length > 0 && entries[ 0 ].isIntersecting) {
loadMoreHandler ()
}
} ,
{
root : null , // Отслеживание относительно окна браузера (viewport). null = весь экран
rootMargin , // Начинать загрузку до появления элемента
threshold , // Срабатывать когда % элемента становится видимым
}
)
const currentObserverRef = observerRef .current
if (currentObserverRef) {
// начинает наблюдение за элементом
observer .observe (currentObserverRef)
}
// Функция очистки - прекращает наблюдение при размонтировании компонента
return () => {
if (currentObserverRef) {
observer .unobserve (currentObserverRef)
}
}
} , [loadMoreHandler , rootMargin , threshold])
return { observerRef }
}
⚠️
Перенесите стили из TracksPage.module.css в TracksList.module.css
import type { TrackData } from '../../../api/tracksApi.types.ts'
import s from './TracksList.module.css'
type Props = {
tracks : TrackData []
}
export const TracksList = ({ tracks } : Props ) => {
return (
< div className = { s .list}>
{ tracks .map (track => {
const { title , user , attachments } = track .attributes
return (
< div key = { track .id} className = { s .item}>
< div >
< p >Title: {title}</ p >
< p >Name: { user .name}</ p >
</ div >
{ attachments . length ? < audio controls src = {attachments[ 0 ].url} /> : 'no file' }
</ div >
)
})}
</ div >
)
}
import type { RefObject } from 'react'
type Props = {
observerRef : RefObject < HTMLDivElement | null >
isFetchingNextPage : boolean
}
export const LoadingTrigger = ({ observerRef , isFetchingNextPage } : Props ) => {
// Этот элемент отслеживается IntersectionObserver
return (
< div ref = {observerRef}>
{ /*`<div style={{ height: '20px' }} />` создает "невидимую зону" в 20px в конце списка,*/ }
{ /*при достижении которой автоматически загружаются новые треки. Без размеров*/ }
{ /*IntersectionObserver не будет работать корректно.*/ }
{isFetchingNextPage ? < div >Loading more tracks...</ div > : < div style = {{ height : '20px' }} />}
</ div >
)
}
export const TracksPage = () => {
const { data , isFetching , isFetchingNextPage , fetchNextPage , hasNextPage } =
useFetchTracksInfiniteQuery ()
const { observerRef } = useInfiniteScroll ({ hasNextPage , isFetching , fetchNextPage })
const pages = data ?. pages .flatMap (page => page .data) || []
return (
< div >
< h1 >Tracks page</ h1 >
< TracksList tracks = {pages} />
{hasNextPage && (
< LoadingTrigger isFetchingNextPage = {isFetchingNextPage} observerRef = {observerRef} />
)}
{ ! hasNextPage && pages . length > 0 && < p >Nothing more to load</ p >}
</ div >
)
}
Результат: декомпозиция завершена, код работает 🚀
Offset пагинация — это способ разбивки данных на страницы, при котором указывается, с какого элемента начинать выборку
(offset) и сколько элементов взять (limit).
export const tracksApi = baseApi .injectEndpoints ({
endpoints : build => ({
fetchTracks : build .infiniteQuery < FetchTracksResponse , void , number >({
infiniteQueryOptions : {
initialPageParam : 1 ,
getNextPageParam : (lastPage , _allPages , lastPageParam) => {
return lastPageParam < ( lastPage .meta as { pagesCount : number }).pagesCount
? lastPageParam + 1
: undefined
} ,
} ,
query : ({ pageParam }) => {
return {
url : 'playlists/tracks' ,
params : { pageNumber : pageParam , pageSize : 10 , paginationType : 'offset' } ,
}
} ,
}) ,
}) ,
})
export const { useFetchTracksInfiniteQuery } = tracksApi