RTK Query Полный курс, часть 1 — Application Setup, Queries, Mutations, Re-fetching & Code Splitting

🟦Альтернативная ссылка: Если у вас не загружается видео на 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

🔶 1. Настройка приложения

Чтобы сосредоточиться на изучении RTK, сделаем базовые настройки для приложения

Настройка импортов / алиасов

Теория

В файле tsconfig.json есть параметр baseUrl для указания корневой директории, относительно которой TypeScript будет разрешать пути к модулям.

Преимущества использования baseUrl:

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

Практика

Откройте файл tsconfig.app.json и добавьте в compilerOptions свойства baseUrl и paths:

tsconfig.app.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
    /*...*/
  },
  "include": ["src"]
}

Откройте файл vite.config.ts и добавьте в конфигурацию свойство resolve с полем alias:

vite.config.ts
import path from 'path'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
 
// https://vite.dev/config/
export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@/': `${path.resolve(__dirname, 'src')}/`,
    },
  },
})

Чтобы разрешить импорт path и переменной __dirname добавьте в devDependencies проекта типизацию node:

Terminal
pnpm add @types/node -D

Роутинг / Структура приложения

Настроим базовый роутинг в проекте.

Установите библиотеку для работы с роутингом React Router:

Terminal
pnpm add react-router
⚠️

В данном приложении будем использовать Declarative mode

❗ Чтобы можно было работать с роутингом, обернём всё приложение в BrowserRouter в файле main.tsx

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

  • главная страница (MainPage.tsx),
  • страница с плейлистами (PlaylistsPage.tsx),
  • страница с треками (TracksPage.tsx),
  • страница профиля (ProfilePage.tsx),
  • страницу 404 (PageNotFound.tsx)
⚠️

В качестве структуры папок используем рекомендации, которые предлагает RTK

  • MainPage.tsx
src/app/MainPage.tsx
export const MainPage = () => {
  return (
    <div>
      <h1>Main page</h1>
    </div>
  )
}
  • PlaylistsPage.tsx
src/features/playlists/ui/PlaylistsPage/PlaylistsPage.tsx
export const PlaylistsPage = () => {
  return (
    <div>
      <h1>Playlists page</h1>
    </div>
  )
}
  • TracksPage.tsx
src/features/tracks/ui/TracksPage/TracksPage.tsx
export const TracksPage = () => {
  return (
    <div>
      <h1>Tracks page</h1>
    </div>
  )
}
  • ProfilePage.tsx
src/features/auth/ui/ProfilePage/ProfilePage.tsx
export const ProfilePage = () => {
  return (
    <div>
      <h1>Profile page</h1>
    </div>
  )
}
  • PageNotFound.tsx
src/common/components/PageNotFound/PageNotFound.tsx
import s from './PageNotFound.module.css'
 
export const PageNotFound = () => {
  return (
    <>
      <h1 className={s.title}>404</h1>
      <h2 className={s.subtitle}>page not found</h2>
    </>
  )
}
  • PageNotFound.module.css
src/common/components/PageNotFound/PageNotFound.module.css
.title {
  text-align: center;
  font-size: 250px;
  margin: 0;
}
 
.subtitle {
  text-align: center;
  font-size: 50px;
  margin: 0;
  text-transform: uppercase;
}
  • Routing.tsx
src/common/routing/Routing.tsx
export const Path = {
  Main: '/',
  Playlists: '/playlists',
  Tracks: '/tracks',
  Profile: '/profile',
  NotFound: '*',
} as const
 
export const Routing = () => (
  <Routes>
    <Route path={Path.Main} element={<MainPage />} />
    <Route path={Path.Playlists} element={<PlaylistsPage />} />
    <Route path={Path.Tracks} element={<TracksPage />} />
    <Route path={Path.Profile} element={<ProfilePage />} />
    <Route path={Path.NotFound} element={<PageNotFound />} />
  </Routes>
)

И добавим Routing.tsx в App.tsx

tsx
export const App = () => {
  return (
    <>
      <Routing />
    </>
  )
}

Результат: При изменении адреса в URL будет отображаться соответствующая страница 🚀

Реализуем Header для того, чтобы осуществлять навигацию по приложению

src/common/components/Header/Header.tsx
import { NavLink } from 'react-router'
import { Path } from '@/common/routing/Routing'
import s from './Header.module.css'
 
const navItems = [
  { to: Path.Main, label: 'Main' },
  { to: Path.Playlists, label: 'Playlists' },
  { to: Path.Tracks, label: 'Tracks' },
  { to: Path.Profile, label: 'Profile' },
]
 
export const Header = () => {
  return (
    <header className={s.container}>
      <nav>
        <ul className={s.list}>
          {navItems.map(item => (
            <li key={item.to}>
              <NavLink
                to={item.to}
                className={({ isActive }) => `link ${isActive ? s.activeLink : ''}`}
              >
                {item.label}
              </NavLink>
            </li>
          ))}
        </ul>
      </nav>
    </header>
  )
}
src/common/components/Header/Header.module.css
.container {
  border-bottom: 1px solid black;
  padding-left: 100px;
}
 
.list {
  display: flex;
  gap: 40px;
}
 
.activeLink {
  font-weight: bold;
}

Добавим Header.tsx в App.tsx

App.tsx
export const App = () => {
  return (
    <>
      <Header />
      <Routing />
    </>
  )
}

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

Layout

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

src/app/App.module.css
.layout {
  max-width: 1186px;
  margin: 0 auto 200px;
}
src/app/App.tsx
export const App = () => {
  return (
    <>
      <Header />
      <div className={s.layout}>
        <Routing />
      </div>
    </>
  )
}

Результат: Контент основных страниц будет размещен в layout 🚀


🔶 2. RTK query

Установите Redux Toolkit и React Redux:

Terminal
pnpm add @reduxjs/toolkit react-redux

Client State vs Server State

Client StateServer State
- Поля формы, введённые пользователем- Результаты запроса из базы данных
- Выбранные фильтры на странице
- Темная / светлая тема
- Локальные данные для интерфейса, например, текущая страница в пагинации.
- Данные, хранящиеся в локальном хранилище (LocalStorage, SessionStorage)
- Временное состояние в React-компонентах (useState)
- Модальные окна
- Сложные формы (wizard)
🛠️Tools for Client State🛠️Tools for Server State
Redux Toolkit (slice) / useState / useReducer / Zustand / ContextRTK Query / TanStack Query / SWR

Разница между Client State и Server State заключается в способах хранения, обработки и управления информацией:

ХарактеристикаClient StateServer State
Где хранитсяНа стороне клиента (в браузере, локальном приложении).На стороне сервера.
Примеры- поля формы;
- выбранные фильтры;
- локальные данные интерфейса (например, текущая страница);
- localStorage, sessionStorage;
- useState в React.
- результаты запросов из БД;
- состояние аутентификации;
- данные о заказах;
- состояние чата через WebSocket.
Преимущества- быстрота доступа без запросов на сервер;
- автономная работа в оффлайн-режиме (при наличии).
- централизованное управление данными;
- высокий уровень безопасности;
- простая синхронизация между клиентами.
Недостатки- ограниченный объём данных;
- сложность синхронизации с сервером;
- уязвимость к модификациям.
- задержки из-за сетевых запросов;
- зависимость от соединения;
- нагрузка на сервер.

RTK query - теория

RTK Query -- библиотека для управления запросами к API и состоянием приложения в React- приложениях с использованием Redux Toolkit. RTK Query предоставляет полезный функционал: кэширование запросов, автоматическая обработка ошибок, управление загрузкой данных и др.

Основная идея RTK Query -- автоматическая генерация Redux Slice (набор Redux-действий и редьюсеров) на основе API-эндпоинта, а затем предоставление хуков для использования в компонентах React.

Особенности RTK Query:

  • автоматически кэширует ответы на запросы и обновляет их только при необходимости;
  • может автоматически нормализовать данные, полученные от API, чтобы облегчить их использование в приложении;
  • позволяет настраивать различные параметры запросов: время ожидания, повторные попытки, тип запроса и другие;
  • автоматически отлавливает ошибки, связанные с запросами, и предоставляет удобные способы их обработки в приложении;
  • управляет состоянием данных в приложении и предоставляет способы их получения и обновления из любой части приложения.

RTK Query упрощает работу с API и управление состоянием, позволяя разработчикам сосредоточиться на бизнес-логике приложения, а не на деталях реализации запросов и управления состоянием.

Queries

Queries используются для получения данных с сервера.

Базовое применение

  • playlistsApi.ts
playlistsApi.ts
// Во избежание ошибок импорт должен быть из `@reduxjs/toolkit/query/react`
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
 
// `createApi` - функция из `RTK Query`, позволяющая создать объект `API`
// для взаимодействия с внешними `API` и управления состоянием приложения
export const playlistsApi = createApi({
  // `reducerPath` - имя куда будут сохранены состояние и экшены для этого `API`
  reducerPath: 'playlistsApi',
  // `baseQuery` - конфигурация для `HTTP-клиента`, который будет использоваться для отправки запросов
  baseQuery: fetchBaseQuery({
    baseUrl: import.meta.env.VITE_BASE_URL,
    headers: {
      'API-KEY': import.meta.env.VITE_API_KEY,
    },
  }),
  // `endpoints` - метод, возвращающий объект с эндпоинтами для `API`, описанными
  // с помощью функций, которые будут вызываться при вызове соответствующих методов `API`
  // (например `get`, `post`, `put`, `patch`, `delete`)
  endpoints: build => ({
    // Типизация аргументов (<возвращаемый тип, тип query аргументов (`QueryArg`)>)
    // `query` по умолчанию создает запрос `get` и указание метода необязательно
    fetchPlaylists: build.query<PlaylistsResponse, FetchPlaylistsArgs>({
      query: () => {
        return {
          method: 'get',
          url: `playlists`,
        }
      },
    }),
  }),
})
 
// `createApi` создает объект `API`, который содержит все эндпоинты в виде хуков,
// определенные в свойстве `endpoints`
export const { useFetchPlaylistsQuery } = playlistsApi
🔗
  • VITE_BASE_URL берем из swagger документации

  • VITE_API_KEY берем из apihub на котором предварительно нужно зарегистрироваться

  • playlistsApi.types.ts
playlistsApi.types.ts
import type { CurrentUserReaction } from '@/common/enums'
import type { Images, Tag, User } from '@/common/types'
 
export type PlaylistsResponse = {
  data: PlaylistData[]
  meta: PlaylistMeta
}
 
export type PlaylistData = {
  id: string
  type: 'playlists'
  attributes: PlaylistAttributes
}
 
export type PlaylistMeta = {
  page: number
  pageSize: number
  totalCount: number
  pagesCount: number
}
 
export type PlaylistAttributes = {
  title: string
  description: string
  addedAt: string
  updatedAt: string
  order: number
  dislikesCount: number
  likesCount: number
  tags: Tag[]
  images: Images
  user: User
  currentUserReaction: CurrentUserReaction
}
 
// Arguments
export type FetchPlaylistsArgs = {
  pageNumber?: number
  pageSize?: number
  search?: string
  sortBy?: 'addedAt' | 'likesCount'
  sortDirection?: 'asc' | 'desc'
  tagsIds?: string[]
  userId?: string
  trackId?: string
}
  • common/types/types.ts
common/types/types.ts
export type Tag = {
  id: string
  name: string
}
 
export type User = {
  id: string
  name: string
}
 
export type Images = {
  main: Cover[]
}
 
export type Cover = {
  type: 'original' | 'medium' | 'thumbnail'
  width: number
  height: number
  fileSize: number
  url: string
}
  • common/enums/enums.ts
common/enums/enums.ts
export const CurrentUserReaction = {
  Like: 1,
  Dislike: -1,
  None: 0,
} as const
 
export type CurrentUserReaction = (typeof CurrentUserReaction)[keyof typeof CurrentUserReaction]

Store

1. Настройка store

В файле store.ts подключите playlistsApi, добавьте middleware для использования дополнительных функций RTK Query: кэширование, инвалидация и pooling, и установите setupListeners для подключения слушателя событий фокуса (refetchOnFocus) и повторного подключения (refetchOnReconnect), чтобы автоматически перезагружать данные при возвращении на страницу или восстановлении подключения:

store.ts
import { configureStore } from '@reduxjs/toolkit'
import { setupListeners } from '@reduxjs/toolkit/query'
import { playlistsApi } from '@/features/playlists/api/playlistsApi.ts'
 
export const store = configureStore({
  reducer: {
    [playlistsApi.reducerPath]: playlistsApi.reducer,
  },
  middleware: getDefaultMiddleware => getDefaultMiddleware().concat(playlistsApi.middleware),
})
 
setupListeners(store.dispatch)

2. Provider

Чтобы в компонентах можно было обращаться к store, нужно обернуть приложение Provider'ом с переданным ему store в файле main.tsx:

main.tsx
createRoot(document.getElementById('root')!).render(
  <BrowserRouter>
    <Provider store={store}>
      <App />
    </Provider>
  </BrowserRouter>
)

3. Redux devtools

  • Установите Redux devtools
  • Откройте панель разработчика и убедитесь, что playlistsApi подключен:

devtools

Query хук

В компоненте PlaylistsPage.tsx вызовите автоматически сгенерированный хук useFetchPlaylistsQuery и отрисуйте полученные данные

PlaylistsPage.tsx
export const PlaylistsPage = () => {
  const { data } = useFetchPlaylistsQuery()
 
  return (
    <div>
      <h1>Playlists page</h1>
      <div>
        {data?.data.map(playlist => {
          return (
            <div key={playlist.id}>
              <div>title: {playlist.attributes.title}</div>
            </div>
          )
        })}
      </div>
    </div>
  )
}

Результат: Плейлисты получены с сервера и отрисованы 🚀

Возвращаемые значения из хука

На каждое изменение статуса промиса мы получаем обновленный объект с данными о запросе:

  • data - данные, которые вернул запрос;
  • isLoading - флаг, который показывает, что запрос выполняется;
  • isError - флаг, который показывает, что запрос завершился с ошибкой;
  • error - объект ошибки;
  • др.

Query Hook Options

Сгенерированный хук может принимать параметры:

  • queryArg - данные для запроса;
  • queryOptions - объект с настройками для управления процессом получения данных.

Отображение плейлистов

Добавим больше информации о плейлистах в разметке и настроим базовые стили

PlaylistsPage.tsx
export const PlaylistsPage = () => {
  const { data } = useFetchPlaylistsQuery()
 
  return (
    <div className={s.container}>
      <h1>Playlists page</h1>
      <div className={s.items}>
        {data?.data.map(playlist => {
          return (
            <div className={s.item} key={playlist.id}>
              <div>title: {playlist.attributes.title}</div>
              <div>description: {playlist.attributes.description}</div>
              <div>userName: {playlist.attributes.user.name}</div>
            </div>
          )
        })}
      </div>
    </div>
  )
}
PlaylistsPage.module.css
.container {
  display: flex;
  flex-direction: column;
  gap: 30px;
}
 
.items {
  display: flex;
  gap: 30px;
  flex-wrap: wrap;
}
 
.item {
  width: 240px;
  padding: 16px;
  border: 1px solid #ddd;
  border-radius: 8px;
}

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


🔶 3. Mutation

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

Создание плейлиста

Api

Реализуем логику создания плейлиста согласно документации.

При изменении данных будем использовать метод mutation у build:

playlistsApi.ts
export const playlistsApi = createApi({
  /*...*/
  endpoints: build => ({
    /*...*/
    createPlaylist: build.mutation<{ data: PlaylistData }, CreatePlaylistArgs>({
      query: body => ({
        url: 'playlists',
        method: 'post',
        body,
      }),
    }),
  }),
})
 
export const { useFetchPlaylistsQuery, useCreatePlaylistMutation } = playlistsApi
playlistsApi.types.ts
/*...*/
// Arguments
export type CreatePlaylistArgs = {
  title: string
  description: string
}

UI

Для того чтобы создать новый плейлист, нам необходимо создать форму, куда будем вводить данные и потом передавать их на сервер. Для создания формы установим библиотеку react-hook-form

Terminal
pnpm add react-hook-form

Создадим форму и выведем данные в консоль

src/features/playlists/ui/PlaylistsPage/CreatePlaylistForm/CreatePlaylistForm.tsx
export const CreatePlaylistForm = () => {
  const { register, handleSubmit } = useForm<CreatePlaylistArgs>()
 
  const onSubmit: SubmitHandler<CreatePlaylistArgs> = data => {
    console.log(data)
  }
 
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <h2>Create new playlist</h2>
      <div>
        <input {...register('title')} placeholder={'title'} />
      </div>
      <div>
        <input {...register('description')} placeholder={'description'} />
      </div>
      <button>create playlist</button>
    </form>
  )
}
PlaylistsPage.tsx
export const PlaylistsPage = () => {
  const { data } = useFetchPlaylistsQuery()
 
  return (
    <div className={s.container}>
      <h1>Playlists page</h1>
      <CreatePlaylistForm />
      {/*...*/}
    </div>
  )
}

create-playlist-form-console

Результат: Введем данные в форму и убедимся, что данные собираются и выводятся в консоль 🚀

useMutation

Сгенерированный хук useMutation возвращает массив:

tsx
const [createPlaylist, result] = useCreatePlaylistMutation()
  1. Первый параметр - функция для инициации запроса;
  2. Второй параметр - объект с данными о запросе.
CreatePlaylistForm.tsx
export const CreatePlaylistForm = () => {
  const { register, handleSubmit } = useForm<CreatePlaylistArgs>()
 
  const [createPlaylist] = useCreatePlaylistMutation()
 
  const onSubmit: SubmitHandler<CreatePlaylistArgs> = data => {
    createPlaylist(data)
  }
 
  /*...*/
}

Теперь давайте сделаем запрос и посмотрим на результат

401-error

Эта ошибка означает, что создавать плейлисты может только авторизованный пользователь. Чтобы бэкенд понял, кто к нему стучится, необходимо в Header передать accessToken полученный при логинизации.

⚠️

Тема авторизации довольно сложная и не хочется сейчас начинать с нее. Поэтому мы залогинимся при помощи Swagger, получим accessToken и прикрепим его к запросу.

❗Авторизацию в RTK query разберем позже.

Полученный accessToken положим в .env.local

.env.local
VITE_BASE_URL=https://musicfun.it-incubator.app/api/1.0
VITE_API_KEY=xxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx
VITE_ACCESS_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxIiwibG9naW4iOiJzYWZyb25tYW4iLCJpYXQiOjE3NTIyMzk2OTIsImV4cCI6MTc1MjMyNjA5Mn0.bfQs5OgJKwKBQj8uk1uOEW1I7eIyWKtQTOXMvB0yxoo

accessToken нужно прикреплять ко всем запросам, поэтому доработаем playlistsApi.ts

playlistsApi.ts
export const playlistsApi = createApi({
  reducerPath: 'playlistsApi',
  baseQuery: fetchBaseQuery({
    baseUrl: import.meta.env.VITE_BASE_URL,
    headers: {
      'API-KEY': import.meta.env.VITE_API_KEY,
    },
    prepareHeaders: headers => {
      headers.set('Authorization', `Bearer ${import.meta.env.VITE_ACCESS_TOKEN}`)
      return headers
    },
  }),
  /*...*/
})

Еще раз пробуем создать плейлист. Открываем панель разработчика и видим, что запрос успешно проходит

create-playlist-success

accessToken цепляется в Headers

accessToken

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

❗После успешного создания плейлиста, зачистим форму ввода

CreatePlaylistForm.tsx
export const CreatePlaylistForm = () => {
  const { register, handleSubmit, reset } = useForm<CreatePlaylistArgs>()
 
  const [createPlaylist] = useCreatePlaylistMutation()
 
  const onSubmit: SubmitHandler<CreatePlaylistArgs> = data => {
    createPlaylist(data).then(() => {
      reset()
    })
  }
 
  /*...*/
}

Удаление плейлиста

Реализуем логику удаления плейлиста согласно документации.

playlistsApi.ts
export const playlistsApi = createApi({
  /*...*/
  endpoints: build => ({
    /*...*/
    deletePlaylist: build.mutation<void, string>({
      query: playlistId => ({
        url: `playlists/${playlistId}`,
        method: 'delete',
      }),
    }),
  }),
})

В компоненте PlaylistsPage.tsx используйте сгенерированный хук useDeletePlaylistMutation:

PlaylistsPage.tsx
export const PlaylistsPage = () => {
  const { data } = useFetchPlaylistsQuery()
  const [deletePlaylist] = useDeletePlaylistMutation()
 
  const deletePlaylistHandler = (playlistId: string) => {
    if (confirm('Are you sure you want to delete the playlist?')) {
      deletePlaylist(playlistId)
    }
  }
 
  return (
    <div className={s.container}>
      <h1>Playlists page</h1>
      <CreatePlaylistForm />
      <div className={s.items}>
        {data?.data.map(playlist => {
          return (
            <div className={s.item} key={playlist.id}>
              <div>title: {playlist.attributes.title}</div>
              <div>description: {playlist.attributes.description}</div>
              <div>userName: {playlist.attributes.user.name}</div>
              <button onClick={() => deletePlaylistHandler(playlist.id)}>delete</button>
            </div>
          )
        })}
      </div>
    </div>
  )
}

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

Обновление плейлиста

Шаг 1

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

playlistsApi.ts
export const playlistsApi = createApi({
  /*...*/
  endpoints: build => ({
    /*...*/
    updatePlaylist: build.mutation<void, { playlistId: string; body: UpdatePlaylistArgs }>({
      query: ({ playlistId, body }) => ({
        url: `playlists/${playlistId}`,
        method: 'put',
        body,
      }),
    }),
  }),
})
playlistsApi.types.ts
/*...*/
// Arguments
export type CreatePlaylistArgs = {
  title: string
  description: string
}
 
export type UpdatePlaylistArgs = {
  title: string
  description: string
  tagIds: string[]
}

В компоненте PlaylistsPage:

  • добавим кнопку для редактирования плейлиста
  • воспользуемся сгенерированным хуком updatePlaylist
⚠️
В body временно будем передавать hardcode данные
PlaylistsPage.tsx
export const PlaylistsPage = () => {
  /*...*/
 
  const updatePlaylistHandler = (playlistId: string) => {
    updatePlaylist({
      playlistId,
      body: {
        title: '1',
        description: '2',
        tagIds: [],
      },
    })
  }
 
  return (
    <div className={s.container}>
      {/*...*/}
      <div className={s.items}>
        {data?.data.map(playlist => {
          return (
            <div className={s.item} key={playlist.id}>
              {/*...*/}
              <button onClick={() => deletePlaylistHandler(playlist.id)}>delete</button>
              <button onClick={() => updatePlaylistHandler(playlist.id)}>update</button>
            </div>
          )
        })}
      </div>
    </div>
  )
}

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

Шаг 2

  1. При нажатии на кнопку update нам необходимо показывать форму, куда мы будем вводить данные. Но формы хотим показывать только для одного плейлиста, а не для всех. Поэтому добавим const [playlistId, setPlaylistId] = useState<string | null>(null)

  2. Когда мапятся плейлисты проверяем, есть ли плейлист, который нужно редактировать const isEditing = playlistId === playlist.id и если такой находится, то отрисовываем форму редактирования

  3. При нажатии на кнопку update (editPlaylistHandler) при помощи reset устанавливаем значения для плейлиста

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

  5. При нажатии на кнопку cancel закрываем форму редактирования

PlaylistsPage.tsx
export const PlaylistsPage = () => {
  // 1
  const [playlistId, setPlaylistId] = useState<string | null>(null)
  const { register, handleSubmit, reset } = useForm<UpdatePlaylistArgs>()
 
  const { data } = useFetchPlaylistsQuery()
  const [deletePlaylist] = useDeletePlaylistMutation()
  const [updatePlaylist] = useUpdatePlaylistMutation()
 
  const deletePlaylistHandler = (playlistId: string) => {
    if (confirm('Are you sure you want to delete the playlist?')) {
      deletePlaylist(playlistId)
    }
  }
 
  // 3, 5
  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)
    }
  }
 
  // 4
  const onSubmit: SubmitHandler<UpdatePlaylistArgs> = data => {
    if (!playlistId) return
    updatePlaylist({ playlistId, body: data }).then(() => {
      setPlaylistId(null)
    })
  }
 
  return (
    <div className={s.container}>
      <h1>Playlists page</h1>
      <CreatePlaylistForm />
      <div className={s.items}>
        {data?.data.map(playlist => {
          // 2
          const isEditing = playlistId === playlist.id
 
          return (
            <div className={s.item} key={playlist.id}>
              {isEditing ? (
                <form onSubmit={handleSubmit(onSubmit)}>
                  <h2>Edit playlist</h2>
                  <div>
                    <input {...register('title')} placeholder={'title'} />
                  </div>
                  <div>
                    <input {...register('description')} placeholder={'description'} />
                  </div>
                  <button type={'submit'}>save</button>
                  <button type={'button'} onClick={() => editPlaylistHandler(null)}>
                    cancel
                  </button>
                </form>
              ) : (
                <div>
                  <div>title: {playlist.attributes.title}</div>
                  <div>description: {playlist.attributes.description}</div>
                  <div>userName: {playlist.attributes.user.name}</div>
                  <button onClick={() => deletePlaylistHandler(playlist.id)}>delete</button>
                  <button onClick={() => editPlaylistHandler(playlist)}>update</button>
                </div>
              )}
            </div>
          )
        })}
      </div>
    </div>
  )
}

Результат: мы успешно реализовали CRUD операции с плейлистами 🚀

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

Прежде чем двигаться дальше, давайте немного зарефакторим код и создадим отдельные компоненты:

  • для плейлиста (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>
      <div>title: {playlist.attributes.title}</div>
      <div>description: {playlist.attributes.description}</div>
      <div>userName: {playlist.attributes.user.name}</div>
      <button onClick={() => deletePlaylist(playlist.id)}>delete</button>
      <button onClick={() => editPlaylist(playlist)}>update</button>
    </div>
  )
}
  • для редактирования формы (EditPlaylistForm.tsx)
EditPlaylistForm.tsx
type Props = {
  playlistId: string
  register: UseFormRegister<UpdatePlaylistArgs>
  handleSubmit: UseFormHandleSubmit<UpdatePlaylistArgs>
  editPlaylist: (playlist: null) => void
  setPlaylistId: (playlistId: null) => void
}
 
export const EditPlaylistForm = ({
  playlistId,
  handleSubmit,
  register,
  editPlaylist,
  setPlaylistId,
}: Props) => {
  const [updatePlaylist] = useUpdatePlaylistMutation()
 
  const onSubmit: SubmitHandler<UpdatePlaylistArgs> = data => {
    if (!playlistId) return
    updatePlaylist({ playlistId, body: data }).then(() => {
      setPlaylistId(null)
    })
  }
 
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <h2>Edit playlist</h2>
      <div>
        <input {...register('title')} placeholder={'title'} />
      </div>
      <div>
        <input {...register('description')} placeholder={'description'} />
      </div>
      <button type={'submit'}>save</button>
      <button type={'button'} onClick={() => editPlaylist(null)}>
        cancel
      </button>
    </form>
  )
}
  • код после рефакторинга (PlaylistsPage.tsx)
PlaylistsPage.tsx
export const PlaylistsPage = () => {
  const [playlistId, setPlaylistId] = useState<string | null>(null)
 
  const { register, handleSubmit, reset } = useForm<UpdatePlaylistArgs>()
 
  const { data } = useFetchPlaylistsQuery()
 
  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.container}>
      <h1>Playlists page</h1>
      <CreatePlaylistForm />
      <div className={s.items}>
        {data?.data.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>
    </div>
  )
}

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


🔶 4. Обновление данных

Automated Re-fetching

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

Рефетчинг данных в RTK query реализуется при помощи системы тегов:

"Tag" -- строка или объект, позволяющий именовать определенные типы данных и инвалидировать части кэша. При инвалидации кэш-тега RTK Query автоматически повторно запрашивает данные с через эндпоинты, помеченных этим тегом:

Для использования тегов добавьте:

  1. Свойство tagTypes в API-slice, объявляющее массив имен тегов для типов данных, таких как "Playlist";
  2. Массив providesTags в query-эндпоинте, перечисляющий набор тегов, описывающих получаемые в этом запросе данные;
  3. Массив invalidatesTags в mutation-эндпоинтах, перечисляющий набор тегов, которые инвалидируются при выполнении этих мутаций:
playlistsApi.ts
export const playlistsApi = createApi({
  /*...*/
  tagTypes: ['Playlist'],
  endpoints: build => ({
    fetchPlaylists: build.query<PlaylistsResponse, void>({
      query: () => ({ url: `playlists` }),
      providesTags: ['Playlist'],
    }),
    createPlaylist: build.mutation<{ data: PlaylistData }, CreatePlaylistArgs>({
      query: body => ({ url: 'playlists', method: 'post', body }),
      invalidatesTags: ['Playlist'],
    }),
    deletePlaylist: build.mutation<void, string>({
      query: playlistId => ({ url: `playlists/${playlistId}`, method: 'delete' }),
      invalidatesTags: ['Playlist'],
    }),
    updatePlaylist: build.mutation<void, { playlistId: string; body: UpdatePlaylistArgs }>({
      query: ({ playlistId, body }) => ({ url: `playlists/${playlistId}`, method: 'put', body }),
      invalidatesTags: ['Playlist'],
    }),
  }),
})

Откройте network и протестируйте выполнение CRUD-операций с плейлистами. Сперва при мутациях идет запрос на саму мутацию, а затем на получение актуальных плейлистов.

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


🔶 5. Code Splitting

RTK Query уменьшает начальный размер бандла, так как позволяет добавлять эндпоинты после настройки базового определения сервиса. Code Splitting в RTK Query делает приложение более оптимизированным, гибким и удобным в поддержке, особенно при работе с большим количеством API в масштабируемых проектах.

Преимущества Code Splitting:

  1. Уменьшение объема начального JavaScript-кода: загружаются только необходимые части приложения, уменьшается размер бандла и сокращается время начальной загрузки;
  2. Повышение производительности: браузеру требуется меньше времени для обработки загруженного кода;
  3. Экономия сетевых ресурсов: логика API-запросов не грузится для страниц, на которых она не используется;
  4. Повышение гибкости разработки: можно добавлять и изменять API-слайсы, не влияя на структуру всего приложения..

Метод injectEndpoints принимает коллекцию эндпоинтов, а также необязательный параметр overrideExisting. Вызов injectEndpoints добавит эндпоинты в исходное API и вернёт это же API с корректными типами для всех эндпоинтов.

  1. Создайте файл app/api/baseApi.ts с одним пустым центральным определением среза API -- baseApi:
src/app/baseApi.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
 
export const baseApi = createApi({
  reducerPath: 'baseApi',
  tagTypes: ['Playlist'],
  baseQuery: fetchBaseQuery({
    baseUrl: import.meta.env.VITE_BASE_URL,
    headers: {
      'API-KEY': import.meta.env.VITE_API_KEY,
    },
    prepareHeaders: headers => {
      headers.set('Authorization', `Bearer ${import.meta.env.VITE_ACCESS_TOKEN}`)
      return headers
    },
  }),
  endpoints: () => ({}),
})
  1. Внедрите эндпоинты playlistsApi в baseApi, используя injectEndpoints:
playlistsApi.ts
export const playlistsApi = baseApi.injectEndpoints({
  endpoints: build => ({
    /*...*/
  }),
})
  1. Подключите baseApi к store:
store.ts
import { configureStore } from '@reduxjs/toolkit'
import { setupListeners } from '@reduxjs/toolkit/query'
import { baseApi } from '../api/baseApi.ts'
 
export const store = configureStore({
  reducer: {
    [baseApi.reducerPath]: baseApi.reducer,
  },
  middleware: getDefaultMiddleware => getDefaultMiddleware().concat(baseApi.middleware),
})
 
setupListeners(store.dispatch)

Результат: все работает с использованием преимуществ, которые дает Code Splitting 🚀