RTK Query Полный курс, часть 2 — Images, Pagination, Caching & Infinite Queries

🟦Альтернативная ссылка: Если у вас не загружается видео на YouTube, вы можете посмотреть его на другой платформе (ВКонтакте) по этой ссылке

🔗 Links

Оценить качество материала и подачу материала автором видео:

Front-end

Трудоустройтесь middle front-end разработчиком на React JS (TypeScript) за 12-16 месяцев обучения с ежедневной менторской поддержкой в формате видео 1 на 1 и коммерческими проектами в портфолио

Перейти на курс
Front-end

Back-end

Трудоустройтесь middle back-end разработчиком за 12-16 месяцев обучения с ежедневной менторской поддержкой в формате видео 1 на 1 и коммерческими проектами в портфолио

Перейти на курс
Back-end

Карьерный бустер

Получите коммерческий опыт на реальных стартапах, прокачайте tech & soft навыки, научитесь работать в команде, проходить собеседования и получите первую работу в IT!

Перейти на курс
Карьерный бустер

Основы Front-end

Сделайте первый шаг в IT, освоив базовые знания разработки и научившись создавать небольшие проекты на JavaScript

Перейти на курс
Основы Front-end

Основы Back-end

Сделайте первый шаг в IT, освоив базовые знания разработки. Без опыта. Без математики. Только практика: JavaScript, SQL, Node JS, база данных

Перейти на курс
Основы Back-end

🔶 6. Работа с изображениями

Добавление обложки

Скачайте картинку по умолчанию (можно использовать свое изображение) и положите ее в директорию (assets/images/)

Чтобы скачать, нажмите на изображение default-playlist-cover

originalCover

Отобразим обложку в плейлисте

PlaylistItem.tsx
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>
  )
}
PlaylistItem.module.css
.cover {
  width: 240px;
  height: 240px;
  object-fit: cover;
}

useUploadPlaylistCoverMutation

Реализуем логику загрузки изображения плейлиста, согласно документации

playlistsApi.ts
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'],
    }),
  }),
})

Логика загрузки изображения

PlaylistItem.tsx
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>
  )
}

Результат: обложка успешно загружается и отображается 🚀

Удаление обложки

Реализуем логику удаления обложки, согласно документации

playlistsApi.ts
export const playlistsApi = baseApi.injectEndpoints({
  endpoints: build => ({
    /*...*/
    deletePlaylistCover: build.mutation<void, { playlistId: string }>({
      query: ({ playlistId }) => ({ url: `playlists/${playlistId}/images/main`, method: 'delete' }),
      invalidatesTags: ['Playlist'],
    }),
  }),
})
PlaylistItem.tsx
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>
  )
}

Результат: обложка удаляется 🚀

Рефакторинг

Декомпозиция

  • PlaylistCover.tsx
PlaylistCover.tsx
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>
  )
}
  • PlaylistDescription.tsx
⚠️

Перенесите стили для обложки из PlaylistItem.module.css в PlaylistCover.module.css

PlaylistDescription.tsx
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>
    </>
  )
}
  • PlaylistItem.tsx
PlaylistItem.tsx
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:

Terminal
pnpm add react-toastify
  • App.tsx
App.tsx
import { ToastContainer } from 'react-toastify'
 
export const App = () => {
  return (
    <>
      <Header />
      <div className={s.layout}>
        <Routing />
      </div>
      <ToastContainer />
    </>
  )
}
  • PlaylistCover.tsx
PlaylistCover.tsx
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

playlistsApi.ts
export const playlistsApi = baseApi.injectEndpoints({
  endpoints: build => ({
    fetchPlaylists: build.query<PlaylistsResponse, FetchPlaylistsArgs>({
      query: params => ({ url: `playlists`, params }),
      providesTags: ['Playlist'],
    }),
    /*...*/
  }),
})

UI

⚠️

Если плейлисты не найдены, то необходимо показать это пользователю

PlaylistsPage.tsx
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 — это техника в программировании, которая ограничивает частоту вызовов функции, чтобы она выполнялась только после того, как прошло определенное время без новых вызовов.

Как это работает?

  1. Пользователь совершает действие (например, ввод текста в поисковой строке).

  2. Вместо немедленного выполнения функции (например, отправки запроса) запускается таймер.

  3. Если действие повторяется до истечения таймера, предыдущий вызов отменяется, и таймер сбрасывается.

  4. Функция выполняется только после того, как таймер полностью истек (т. е. новых вызовов не было).

Реализация

Существует множество способов реализации debounce.

Например, можно воспользоваться готовым универсальным решением useDebounceValue, а можно написать свою упрощенную версию

useDebounceValue.ts
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
}
PlaylistsPage.tsx
export const PlaylistsPage = () => {
  /*...*/
  const [search, setSearch] = useState('')
 
  const debounceSearch = useDebounceValue(search)
  const { data, isLoading } = useFetchPlaylistsQuery({ search: debounceSearch })
  /*...*/
}

Результат: поиск работает с debounce🚀

Пагинация

Теория

Пагинация — это разбиение большого списка данных на отдельные страницы для удобства просмотра и навигации.

Существует множество способов как реализовать пагинацию. Есть множество готовых решений:

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

Base pagination

бэкенд возвращает метаинформацию на основании которой, мы сможем построить пагинацию

ts
export type PlaylistMeta = {
  page: number
  pageSize: number
  totalCount: number
  pagesCount: number
}
  • PlaylistsPage.tsx
PlaylistsPage.tsx
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>
  )
}
  • Pagination.tsx
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.module.css
Pagination.module.css
.pagination {
  display: flex;
  gap: 8px;
  justify-content: center;
  margin-top: 24px;
}
 
.pageButton {
  padding: 4px 10px;
  background: white;
  border: 1px solid #aaa;
  border-radius: 4px;
  cursor: pointer;
}
 
.pageButtonActive {
  background: #ececec;
  cursor: default;
}
 
.ellipsis {
  padding: 4px 10px;
  color: #888;
  user-select: none;
}
  • getPaginationPages.ts
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)

  • PlaylistsPage.tsx
PlaylistsPage
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>
  )
}
  • Pagination.tsx
Pagination.tsx
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>
  )
}
  • Pagination.module.css
Pagination.module.css
.container {
  display: flex;
  align-content: center;
  align-items: center;
  margin: 0 auto;
  gap: 40px;
}
 
.pagination {
  display: flex;
  gap: 8px;
  justify-content: center;
  /*margin-top: 24px;  ❌ remove */
}

Результат: внедрили в пагинацию возможность менять кол-во страниц 🚀

Рефакторинг

Бага в поиске

Воспрозведение баги:

  • на первой странице найдите плейлист, который хотите найти. Например, с названием cat
  • смените страницу на любую другую и попробуйте найти данный плейлист. Вы его не найдете, т.к. pageNumber изменился
  • чтобы решить эту проблему, необходимо при поиске всегда сбрасывать pageNumber на первую страницу
PlaylistsPage.tsx
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 стал большим и довольно сложно читаемым, поэтому прежде чем двигаться дальше декомпозируем его

  • PlaylistsPage.tsx
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>
  )
}
  • PlaylistsList.tsx
PlaylistsList.tsx
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>
  )
}
  • PlaylistsList.module.css

Перенесите items и item из PlaylistsPage.module.css

PlaylistsList.module.css
.items {
  display: flex;
  gap: 30px;
  flex-wrap: wrap;
}
 
.item {
  width: 240px;
  padding: 16px;
  border: 1px solid #ddd;
  border-radius: 8px;
}

Результат: рефакторинг завершен. Код работает 🚀

Задание для закрепления материала

🏠 В качестве закрепления можете самостоятельно декомпозировать Pagination.tsx на PaginationControls.tsx и PageSizeSelector.tsx


🔶 8. Cache

Кэширование

Откройте network и посмотрите на запросы при переходе на разные страницы. Запросы идут, если страница открывается впервые. Но при открытии страницы повторно запрос не отправляется, потому что данные берутся из кэша.

KeepUnusedDataFor

Время хранения данных в кэше, по умолчанию, составляет 60 секунд. Но это время можно настраивать с помощью keepUnusedDataFor в baseApi.ts:

baseApi.ts
export const baseApi = createApi({
  reducerPath: 'baseApi',
  tagTypes: ['Playlist'],
  keepUnusedDataFor: 5,
  /*...*/
})

Также keepUnusedDataFor можно задавать для конкретного query-запроса.

RefetchOnFocus

В RTK Query refetchOnFocus используется для автоматического повторного запроса за данными, когда окно приложения или вкладка браузера попадают в фокус.

Пример работы:

  • когда пользователь уходит с вкладки, запрос данных не выполняется;
  • когда пользователь возвращается на вкладку (т.е. окно браузера или приложение попадает в фокус), данные автоматически обновляются через запрос на сервер:
baseApi.ts
export const baseApi = createApi({
  reducerPath: 'baseApi',
  tagTypes: ['Playlist'],
  refetchOnFocus: true,
  /*...*/
})

Результат: откройте две вкладки с приложением. Создайте плейлист в одной вкладке, затем перейдите на другую. Там уже подгрузится новый плейлист без перезагрузки страницы 🚀

RefetchOnReconnect

refetchOnReconnect управляет повторным запросом данных, когда приложение или браузер восстанавливает соединение с интернетом после его потери.

Когда это полезно:

  • приложение работает в условиях нестабильного соединения, и нужно гарантировать, что данные всегда актуальны после переподключения;
  • когда в приложении есть данные, которые могут изменяться, пока пользователь был офлайн (например, в социальных сетях, потоковых данных или финансовых приложениях):
baseApi.ts
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 оптимизирует процесс, останавливая запрос, если компонент размонтирован или пользователь не взаимодействует с приложением:

PlaylistsPage.tsx
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) тоже выведем информацию о пользователе.

  • authApi.ts
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
  • authApi.types.ts
src/features/auth/api/authApi.types.ts
export type MeResponse = {
  userId: string
  login: string
}
  • ProfilePage.tsx
ProfilePage.tsx
export const ProfilePage = () => {
  const { data } = useGetMeQuery()
 
  return <h1>{data?.login} page</h1>
}
  • MainPage.tsx
MainPage.tsx
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 и посмотрим на полученный результат

TracksPage.tsx
export const TracksPage = () => {
  const { data } = useFetchTracksInfiniteQuery()
 
  console.log(data)
 
  return (
    <div>
      <h1>Tracks page</h1>
    </div>
  )
}

Обратите внимание, что хук вернул нам структуру {pages: DataType[], pageParams: PageParam[]}, которая содержит все полученные результаты страниц и соответствующие параметры страниц, использованные для их загрузки.

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

infinite query

Отрисуем данные

💡

Чтобы уменьшить вложенность воспользуемся flat или flatMap

  • TracksPage.tsx
TracksPage.tsx
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>
  )
}
  • TracksPage.module.css
TracksPage.module.css
.list {
  display: flex;
  flex-direction: column;
  gap: 20px;
}
 
.item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 16px;
  border: 1px solid #ddd;
  border-radius: 8px;
}

Результат: отрисовали первую страницу 🚀

Load more

TracksPage.tsx
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 треки подгружаются до тех пор, пока полностью не подгрузятся 🚀

Infinity scroll

Мы хотим, чтобы при прокрутке вниз треки автоматически добавлялись. Чтобы реализовать данную задачу, нам необходимо отслеживать момент когда мы домотали до конца страницы и делать сразу же запрос.

Для реализации данной задачи можно использовать библиотеку react-intersection-observer

Но можно обойтись без библиотеки и написать самостоятельно.

TracksPage.tsx
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
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 }
}
  • TracksList.tsx
⚠️

Перенесите стили из TracksPage.module.css в TracksList.module.css

TracksList.tsx
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>
  )
}
  • LoadingTrigger.tsx
LoadingTrigger.tsx
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>
  )
}
  • TracksPage.tsx
TracksPage.tsx
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 Pagination

Offset пагинация — это способ разбивки данных на страницы, при котором указывается, с какого элемента начинать выборку (offset) и сколько элементов взять (limit).

tracksApi.ts
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