RTK Query Полный курс, часть 4 — Auth Flow, Zod & WebSockets

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

🔶 13. Auth

На данный момент мы не авторизовались, но можем делать CRUD-операции, потому что временно мы прописали VITE_ACCESS_TOKEN в .local.env и прикрепили в prepareHeaders

baseApi
export const baseApi = createApi({
  /*...*/
  baseQuery: async (args, api, extraOptions) => {
    const result = await 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
      },
    })(args, api, extraOptions)
    /*...*/
  },
})

Удалите VITE_ACCESS_TOKEN из .env.local

Теперь пришло время разобраться, как делать авторизацию в RTK query

Login

OAuth login

Согласно документации реализуем api слой для login.

  • authApi.ts
authApi.ts
export const authApi = baseApi.injectEndpoints({
  endpoints: build => ({
    /*...*/
    login: build.mutation<LoginResponse, LoginArgs>({
      query: payload => ({
        url: `auth/login`,
        method: 'post',
        body: { ...payload, accessTokenTTL: '3m' },
      }),
    }),
  }),
})
  • authApi.types.ts
authApi.types.ts
/*...*/
 
export type LoginResponse = {
  refreshToken: string
  accessToken: string
}
 
// Arguments
export type LoginArgs = {
  code: string
  redirectUri: string
  rememberMe: boolean
  accessTokenTTL?: string // e.g. "3m"
}

Создадим компонент Login.tsx и разместим данный компонент в Header.tsx

  • Login.tsx
Login.tsx
export const Login = () => {
  const [login] = useLoginMutation()
 
  const loginHandler = () => {
    login({ code: '', redirectUri: '', rememberMe: false })
  }
 
  return (
    <button type={'button'} onClick={loginHandler}>
      login
    </button>
  )
}
  • Header.tsx
Header.tsx
export const Header = () => {
  const { data } = useGetMeQuery()
 
  return (
    <header className={s.container}>
      {/*...*/}
      {data && data.login}
      {!data && <Login />}
    </header>
  )
}
  • Header.module.css
Header.module.css
.container {
  border-bottom: 1px solid black;
  padding: 0 100px;
  display: flex;
  align-items: center;
  justify-content: space-between;
}

OAuth redirect

Теперь нам нужно понять откуда взять code и redirectUri.

Пошаговый план, что мы будем делать:

  • Когда пользователь нажимает на кнопку "login", открывается popup-окно для OAuth-авторизации в которое передаем url

  • После авторизации провайдер OAuth редиректит на /oauth/callback?code=....

  • На этой странице компонент OAuthCallback достаёт code и с помощью postMessage отправляет его в основное окно приложения, а затем закрывает popup-окно.

🔗

window.postMessage — это специальный механизм в браузерах для безопасного обмена сообщениями между окнами, вкладками или фреймами, даже если они с разных доменов (cross-origin). Это один из немногих стандартных способов передачи данных между, например, popup-окном и основным приложением.

  • Основное окно ловит это сообщение, вызывает login mutation (обычно это запрос на бэкенд, где по code выдают токен).

  • После успешного логина пользователь считается авторизованным.

Реализация

  • .env
.env
VITE_BASE_URL=https://musicfun.it-incubator.app/api/1.0
VITE_DOMAIN_ADDRESS=http://localhost:5173
VITE_API_KEY=
  • features/auth/ui/OAuthCallback/OAuthCallback.tsx
OAuthCallback.tsx
// Компонент, срабатывающий после успешной OAuth авторизации,
// его цель - отправить код обратно в главное окно приложения и закрыть popup
export const OAuthCallback = () => {
  useEffect(() => {
    // Получаем текущий URL
    const url = new URL(window.location.href)
 
    // Извлекаем code из параметров запроса
    const code = url.searchParams.get('code')
 
    if (code && window.opener) {
      window.opener.postMessage({ code }, '*')
    }
 
    window.close()
  }, [])
 
  return <p>Logging you in...</p>
}
  • Routing.tsx
Routing.tsx
export const Path = {
  /*...*/
  OAuthRedirect: '/oauth/callback',
  NotFound: '*',
} as const
 
export const Routing = () => (
  <Routes>
    {/*...*/}
    <Route path={Path.OAuthRedirect} element={<OAuthCallback />} />
    <Route path={Path.NotFound} element={<PageNotFound />} />
  </Routes>
)
  • Login.tsx
Login.tsx
export const Login = () => {
  const [login] = useLoginMutation()
 
  const loginHandler = () => {
    // Создаем URI для перенаправления после авторизации
    const redirectUri = import.meta.env.VITE_DOMAIN_ADDRESS + Path.OAuthRedirect
 
    // Создаем URL endpoint OAuth авторизации, добавляя callbackUrl как параметр запроса
    const url = `${import.meta.env.VITE_BASE_URL}/auth/oauth-redirect?callbackUrl=${redirectUri}`
 
    // Открываем всплывающее окно для OAuth авторизации
    window.open(url, 'oauthPopup', 'width=500, height=600')
 
    // Функция-обработчик для получения сообщений из всплывающего окна
    const receiveMessage = async (event: MessageEvent) => {
      if (event.origin !== import.meta.env.VITE_DOMAIN_ADDRESS) return
 
      const { code } = event.data
      if (!code) return
 
      // Отписываемся от события, чтобы избежать обработки дублирующихся сообщений
      window.removeEventListener('message', receiveMessage)
      login({ code, redirectUri, rememberMe: false })
    }
 
    // Подписываемся на сообщения из всплывающего окна
    window.addEventListener('message', receiveMessage)
  }
 
  return (
    <button type={'button'} onClick={loginHandler}>
      login
    </button>
  )
}

Результат: login-запрос уходит и в ответе мы получаем 2 jwt токена: (refreshToken и accessToken) 🚀

AccessToken и refreshToken

Теория

Зачем backend возвращает accessToken и refreshToken?

  1. accessToken (токен доступа)
  • Основной токен для авторизации на клиенте.
  • Используется для запросов к защищённым эндпоинтам (например, «получить профиль пользователя», «создать заказ» и т.д.).
  • Короткоживущий (обычно 5–30 минут): если его украдут, злоумышленник не сможет долго им пользоваться.
  1. refreshToken (токен обновления)
  • Хранится отдельно (чаще всего — в httpOnly cookie).
  • Не используется напрямую для запросов к API!
  • Его задача — обновлять accessToken, когда тот истёк.
  • Долго живёт (например, несколько дней, недель или даже месяцев).

Механизм работы (упрощённо)

  1. Пользователь логинится → backend возвращает оба токена.
  2. Клиент использует accessToken для запросов.
  3. Когда accessToken истекает:
  • 3.1. Клиент делает запрос на обновление (refresh) с помощью refreshToken.
  • 3.2. Backend проверяет refreshToken, и если всё ок — выдаёт новый accessToken (и может обновить refreshToken).
  • 3.3. Пользователь продолжает работать без разлогина.

Зачем это нужно? (основные плюсы)

  • Безопасность. Если accessToken украдут — его действие быстро закончится, а refreshToken хранится максимально безопасно (например, не доступен из JS).
  • Удобство для пользователя. Пользователь не замечает, что accessToken обновляется — сессия «вечная», пока жив refreshToken (и пользователь не вышел из аккаунта).
  • Контроль сессий. Можно отозвать refreshToken на сервере, чтобы разлогинить пользователя на всех устройствах.

Практика

  • common/constants/constants.ts
constants.tsx
export const AUTH_KEYS = {
  accessToken: 'musicfun-access-token',
  refreshToken: 'musicfun-refresh-token',
} as const
  • baseApi.ts
baseApi.ts
export const baseApi = createApi({
  reducerPath: 'baseApi',
  tagTypes: ['Playlist', 'Auth'],
  baseQuery: async (args, api, extraOptions) => {
    const result = await fetchBaseQuery({
      baseUrl: import.meta.env.VITE_BASE_URL,
      headers: {
        'API-KEY': import.meta.env.VITE_API_KEY,
      },
      prepareHeaders: headers => {
        const accessToken = localStorage.getItem(AUTH_KEYS.accessToken)
        if (accessToken) {
          headers.set('Authorization', `Bearer ${accessToken}`)
        }
        return headers
      },
    })(args, api, extraOptions)
 
    if (result.error) {
      handleErrors(result.error)
    }
 
    return result
  },
  endpoints: () => ({}),
})
  • authApi.ts
authApi.ts
export const authApi = baseApi.injectEndpoints({
  endpoints: build => ({
    /*...*/
    login: build.mutation<LoginResponse, LoginArgs>({
      query: payload => ({
        url: `auth/login`,
        method: 'post',
        body: { ...payload, accessTokenTTL: '3m' },
      }),
      async onQueryStarted(_arg, { dispatch, queryFulfilled }) {
        const { data } = await queryFulfilled
        localStorage.setItem(AUTH_KEYS.accessToken, data.accessToken)
        localStorage.setItem(AUTH_KEYS.refreshToken, data.refreshToken)
        // Invalidate after saving tokens
        dispatch(authApi.util.invalidateTags(['Auth']))
      },
    }),
  }),
})

Результат:

  • сохраняем refreshToken и accessToken в localstorage 🚀
  • прикрепляем accessToken к каждому запросу 🚀
  • после логинизации инвалидируем кэш, чтобы сделать me-запрос 🚀

Logout

Реализуем logout

🔗
  • resetApiState сбрасывает состояние всего API slice, включая кэш запросов
  • baseApi.ts
baseApi.ts
export const authApi = baseApi.injectEndpoints({
  endpoints: build => ({
    /*...*/
 
    logout: build.mutation<void, void>({
      query: () => {
        const refreshToken = localStorage.getItem(AUTH_KEYS.refreshToken)
        return { url: 'auth/logout', method: 'post', body: { refreshToken } }
      },
      async onQueryStarted(_args, { queryFulfilled, dispatch }) {
        await queryFulfilled
        localStorage.removeItem(AUTH_KEYS.accessToken)
        localStorage.removeItem(AUTH_KEYS.refreshToken)
        dispatch(baseApi.util.resetApiState())
      },
    }),
  }),
})
  • Header.tsx
Header.tsx
export const Header = () => {
  const { data } = useGetMeQuery()
  const [logout] = useLogoutMutation()
 
  const logoutHandler = () => logout()
 
  return (
    <header className={s.container}>
      {/*...*/}
      {data && (
        <div className={s.loginContainer}>
          <p>{data.login}</p>
          <button onClick={logoutHandler}>logout</button>
        </div>
      )}
      {!data && <Login />}
    </header>
  )
}
  • Header.module.css
Header.module.css
/*...*/
.loginContainer {
  display: flex;
  gap: 10px;
  align-items: center;
}

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

Re-authorization

Теперь разберемся, зачем нам нужен refreshToken и как с ним работать

Чтобы увидеть проблему, при логинизации укажите accessTokenTTL равный например 10s. Залогиньтесь и попробуйте обновить плейлист, у вас все должно работать. Потом подождите 10 секунд и опять попробуйте обновить, должна упасть ошибка, т.к. время жизни accessToken истекло и он стал невалидным.

Чтобы решить эту проблему реализуем /auth/refresh

Будем использовать вариант с async-mutex для предотвращения многократных вызовов '/refreshToken'

Установим библиотеку async-mutex

Terminal
pnpm add async-mutex

Библиотека async-mutex нужна для того, чтобы управлять доступом к общим данным в асинхронном коде. Она помогает избежать ситуаций, когда несколько асинхронных операций одновременно пытаются изменить одни и те же данные (например, переменную, объект или массив), что может привести к ошибкам или некорректному поведению.

Простыми словами: async-mutex позволяет «заблокировать» ресурс для одной операции, чтобы другие ждали своей очереди. Это как очередь в магазин — пока один покупатель на кассе, остальные ждут, чтобы не было путаницы.

  • baseQuery.ts
baseQuery.ts
import { AUTH_KEYS } from '@/common/constants'
import { fetchBaseQuery } from '@reduxjs/toolkit/query/react'
 
export const baseQuery = fetchBaseQuery({
  baseUrl: import.meta.env.VITE_BASE_URL,
  headers: {
    'API-KEY': import.meta.env.VITE_API_KEY,
  },
  prepareHeaders: headers => {
    const accessToken = localStorage.getItem(AUTH_KEYS.accessToken)
    if (accessToken) {
      headers.set('Authorization', `Bearer ${accessToken}`)
    }
    return headers
  },
})
  • baseApi.ts
baseApi.ts
import { baseQueryWithReauth } from '@/app/api/baseQueryWithReauth.ts'
import { createApi } from '@reduxjs/toolkit/query/react'
 
export const baseApi = createApi({
  reducerPath: 'baseApi',
  tagTypes: ['Playlist', 'Auth'],
  endpoints: () => ({}),
  baseQuery: baseQueryWithReauth,
})
  • baseQueryWithReauth.ts
baseQueryWithReauth.ts
import { baseApi } from '@/app/api/baseApi.ts'
import { baseQuery } from '@/app/api/baseQuery.ts'
import { AUTH_KEYS } from '@/common/constants'
import { handleErrors, isTokens } from '@/common/utils'
import type { BaseQueryFn, FetchArgs, FetchBaseQueryError } from '@reduxjs/toolkit/query/react'
import { Mutex } from 'async-mutex'
 
// Создаём новый мьютекс для управления параллельными запросами на обновление токена
const mutex = new Mutex()
 
export const baseQueryWithReauth: BaseQueryFn<
  string | FetchArgs,
  unknown,
  FetchBaseQueryError
> = async (args, api, extraOptions) => {
  // Ждём завершения любого текущего процесса обновления токена (если мьютекс заблокирован)
  await mutex.waitForUnlock()
 
  // Выполняем исходный запрос к API
  let result = await baseQuery(args, api, extraOptions)
 
  // Если запрос завершился ошибкой 401 Unauthorized
  if (result.error && result.error.status === 401) {
    // Проверяем, что мьютекс ещё не заблокирован (то есть другой процесс обновления токена не идёт)
    if (!mutex.isLocked()) {
      // Блокируем мьютекс, чтобы только один процесс обновления токена выполнялся в данный момент
      const release = await mutex.acquire()
      try {
        const refreshToken = localStorage.getItem(AUTH_KEYS.refreshToken)
 
        const refreshResult = await baseQuery(
          { url: '/auth/refresh', method: 'post', body: { refreshToken } },
          api,
          extraOptions
        )
 
        if (refreshResult.data && isTokens(refreshResult.data)) {
          localStorage.setItem(AUTH_KEYS.accessToken, refreshResult.data.accessToken)
          localStorage.setItem(AUTH_KEYS.refreshToken, refreshResult.data.refreshToken)
          // Повторяем исходный запрос с новым access token
          result = await baseQuery(args, api, extraOptions)
        } else {
          // Если обновление токена не удалось — выполняем выход из системы
          // @ts-expect-error
          api.dispatch(baseApi.endpoints.logout.initiate())
        }
      } finally {
        // Всегда освобождаем мьютекс после завершения обновления токена
        release()
      }
    } else {
      // Если процесс обновления токена уже идёт — ждём его завершения
      await mutex.waitForUnlock()
      // После обновления токенов повторяем исходный запрос
      result = await baseQuery(args, api, extraOptions)
    }
  }
 
  // Обрабатываем все ошибки, кроме 401 Unauthorized (они обрабатываются выше)
  if (result.error && result.error.status !== 401) {
    handleErrors(result.error)
  }
 
  return result
}
⚠️

Из handleErrors.ts удалите case 401, т.к. ошибку авторизации мы обрабатываем по-другому

  • commin/utils/isTokens.ts
isTokens.ts
export const isTokens = (data: unknown): data is { accessToken: string; refreshToken: string } => {
  return (
    typeof data === 'object' && data !== null && 'accessToken' in data && 'refreshToken' in data
  )
}

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

Refresh errors

Если мы вылогинимся мы увидим ошибку, т.к. идет refresh запрос с невалидним refresh-токеном.

console
{
  status: "400",
  code: 5,
  title: "Validation failed",
  detail: "refreshToken must be a string; Received value: null",
  source: {
    pointer: "/data/attributes/refreshToken",
  },
  meta: {
    timestamp: "2025-07-22T13:12:47.617Z",
    path: "/api/1.0/auth/refresh",
  },
}

Но мы не можем не показывать ошибки со статусом 400. Нам не нужно это только при ошибке с токеном. Поэтому немного доработаем 400 case

handleErrors
case 400:
  if (isErrorWithDetailArray(error.data)) {
    const errorMessage = error.data.errors[0].detail
    if (errorMessage.includes("refreshToken")) return
    errorToast(trimToMaxLength(errorMessage))
  } else {
    errorToast(JSON.stringify(error.data))
  }
break

Результат: Refresh errors не отображается на UI 🚀


🔶 14. My playlists

Сейчас все плейлисты отрисовываются на странице плейлистов ('/playlists'). Но хотелось бы видеть не абсолютно все плейлисты, а только те, которые мы создали сами. Для этого у нас есть страница профиля.

Чтобы попасть на страницу профиля мы переходим по ссылке, которая находится в Header.tsx. Давайте этот момент тоже сразу переделаем, и на страницу профиля будем переходить по клику на имя пользователя

Playlists by userId

  • Header.tsx
Header.tsx
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}>
      {/*...*/}
      {data && (
        <div className={s.loginContainer}>
          <Link to={Path.Profile}>{data.login}</Link>
          <button onClick={logoutHandler}>logout</button>
        </div>
      )}
      {!data && <Login />}
    </header>
  )
}

Чтобы отфильтровать плейлисты нужно передать userId в query параметры

⚠️

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

  • ProfilePage.tsx
ProfilePage.tsx
export const ProfilePage = () => {
  const { data: meResponse } = useGetMeQuery()
  const { data: playlistsResponse, isLoading } = useFetchPlaylistsQuery({
    userId: meResponse?.userId,
  })
 
  return (
    <div>
      <h1>{meResponse?.login} page</h1>
      <PlaylistsList playlists={playlistsResponse?.data || []} isPlaylistsLoading={isLoading} />
    </div>
  )
}

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

CreatePlaylistForm to profile

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

  • ProfilePage.tsx
ProfilePage.tsx
export const ProfilePage = () => {
  const { data: meResponse } = useGetMeQuery()
  const { data: playlistsResponse, isLoading } = useFetchPlaylistsQuery({
    userId: meResponse?.userId,
  })
 
  return (
    <>
      <h1>{meResponse?.login} page</h1>
      <div className={s.container}>
        <CreatePlaylistForm />
        <PlaylistsList playlists={playlistsResponse?.data || []} isPlaylistsLoading={isLoading} />
      </div>
    </>
  )
}
  • ProfilePage.module.css
ProfilePage.module.css
.container {
  display: flex;
  flex-direction: column;
  gap: 30px;
}
⚠️
Удалите CreatePlaylistForm со страницы PlaylistsPage.tsx

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

Skip

Если на странице профиля открыть developer tools и посмотреть, какие запросы уходят, то вы увидите что за плейлистами уходит 2 запроса, вместо одного:

  • первый запрос без query параметров playlists
  • второй запрос с userId playlists?userId=1

Для того чтобы решить этот вопрос и не отправлять лишний запрос на бэкенд воспользуемся skip

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

Для предотвращения автоматического выполнения запроса используется булево свойство skip в настройках хука.

ProfilePage.tsx
export const ProfilePage = () => {
  const { data: meResponse, isLoading: isMeLoading } = useGetMeQuery()
 
  const { data: playlistsResponse, isLoading } = useFetchPlaylistsQuery(
    { userId: meResponse?.userId },
    { skip: !meResponse?.userId }
  )
 
  if (isLoading || isMeLoading) return <h1>Skeleton loader...</h1>
 
  return (
    <>
      <h1>{meResponse?.login} page</h1>
      <div className={s.container}>
        <CreatePlaylistForm />
        <PlaylistsList
          playlists={playlistsResponse?.data || []}
          isPlaylistsLoading={isLoading || isMeLoading}
        />
      </div>
    </>
  )
}

Результат: теперь уходит только 1 запрос за плейлистами 🚀

Redirect

Если вы вылогинитесь и обновите страницу профиля, то вы не увидите своих плейлистов, т.к. me-запрос будет падать с 401 ошибкой.

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

ProfilePage.tsx
import { Navigate } from 'react-router'
 
export const ProfilePage = () => {
  /*...*/
 
  if (!isMeLoading && !meResponse) return <Navigate to={Path.Playlists} />
 
  /*...*/
}

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


🔶 15. Zod

Теория

Zod — это TypeScript-библиотека для валидации данных и статического вывода типов. Она позволяет безопасно работать с данными, проверяя их структуру и типы на этапе выполнения (runtime), а также автоматически генерирует TypeScript-типы на основе схем валидации.

🔗

Документация по использованию валидации Zod:

🔷Установите библиотеку Zod и @hookform/resolvers:

Terminal
pnpm add zod @hookform/resolvers

🔷 Основные задачи Zod:

1. Валидировать входные данные

  • Проверять, что данные соответствуют нужному формату (например, строка, число, email, массив и т.д.).
  • Полезно при работе с API, формами, запросами, данными из внешних источников.

2. Автоматически выводить типы TypeScript

  • Ты пишешь схему — TypeScript сам выводит тип объекта.
  • Это устраняет дублирование: не нужно отдельно писать интерфейс и валидатор.

3. Упрощать написание безопасного кода

  • Если z.infer<typeof schema> выдает тип, значит, данные точно прошли проверку и с ними можно безопасно работать.

🔷Где применяется?

  • Валидация форм (React, Next.js, Vue и др.).
  • Парсинг API-ответов (например, в fetch-запросах).
  • Конфигурация приложений (env-переменные, JSON-файлы).

🔷Почему выбирают Zod?

  • ✅ Простота и лаконичность синтаксиса.
  • ✅ Отличная TypeScript-поддержка.
  • ✅ Хорошая производительность.
  • ✅ Расширяемость (можно создавать кастомные валидаторы).

Валидация форм

Давайте провалидируем форму для создания нового плейлиста. Если сейчас мы не введем никакие значения в форму и нажмем кнопку create playlist, то увидим серверную ошибку "title must be longer than or equal to 1 characters; Received value:". Это означает, что бэкенд разработчик предусмотрел такой кейс и не разрешает нам создать плейлист с пустым title. Но зачем нам для этого вообще делать запрос, давайте реализуем эту задачу на стороне фронта и таким образом пользователь не будет беспокоить сервер, а сразу увидит ошибку

Нативная валидация через React Hook Form подходит для форм с несложной валидацией и небольшим количеством полей. Если форма имеет много полей со сложными валидациями, то код становится нечитаемым и сложным для поддержки.

Упростить валидацию форм помогают специальные библиотеки, одной из которых является Zod. Она поддерживается React Hook Form с помощью @hookform/resolvers.

1. Определим валидационную схему

  • features/playlists/model/playlists.schemas.ts
playlists.schemas.ts
export const createPlaylistSchema = z.object({
  title: z.string(),
  description: z.string(),
})

2. Добавим валидацию

  • playlists.schemas.ts
playlists.schemas.ts
export const createPlaylistSchema = z.object({
  title: z.string().min(1).max(100),
  description: z.string().max(1000),
})
  • CreatePlaylistForm.tsx
CreatePlaylistForm.tsx
/*...*/
import { type SubmitHandler, useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import s from './CreatePlaylistForm.module.css'
 
export const CreatePlaylistForm = () => {
  const {
    register,
    handleSubmit,
    reset,
    formState: { errors },
  } = useForm<CreatePlaylistArgs>({
    resolver: zodResolver(createPlaylistSchema),
  })
 
  /*...*/
 
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <h2>Create new playlist</h2>
      <div>
        <input {...register('title')} placeholder={'title'} />
        {errors.title && <span className={s.error}>{errors.title.message}</span>}
      </div>
      <div>
        <input {...register('description')} placeholder={'description'} />
        {errors.description && <span className={s.error}>{errors.description.message}</span>}
      </div>
      <button>create playlist</button>
    </form>
  )
}
  • CreatePlaylistForm.module.css
CreatePlaylistForm.module.css
.error {
  color: red;
  font-weight: bold;
}

Результат: форма провалидирована и защищена на фронте 🚀

3. Кастомное сообщение об ошибке

Автоматически сгенерированные сообщения об ошибках меняются с помощью указания нужного текста вручную:

playlists.schemas.ts
export const createPlaylistSchema = z.object({
  title: z
    .string()
    .min(1, 'The title length must be more than 1 character')
    .max(100, 'The title length must be less than 100 characters'),
  description: z.string().max(1000, 'The description length must be less than 1000 characters.'),
})

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

4. Автогенерация типа

Чтобы не писать типы для формы вручную, можно воспользоваться z.infer:

playlistsApi.types.ts
// ❌
// export type CreatePlaylistArgs = {
//   title: string
//   description: string
// }
 
// ✅
export type CreatePlaylistArgs = z.infer<typeof createPlaylistSchema>

Результат: провалидировали форму и автоматически вывели тип 🚀

Валидация ответов с сервера

Playlists

Определим схему для валидации ответа с сервера при получении плейлистов

  • common/schemas/schemas.ts
schemas.ts
export const tagSchema = z.object({
  id: z.string(),
  name: z.string(),
})
 
export const userSchema = z.object({
  id: z.string(),
  name: z.string(),
})
 
export const coverSchema = z.object({
  type: z.literal(['original', 'medium', 'thumbnail']),
  width: z.int().positive(),
  height: z.int().positive(),
  fileSize: z.int().positive(),
  url: z.url(),
})
 
export const imagesSchema = z.object({
  main: z.array(coverSchema),
})
 
export const currentUserReactionSchema = z.enum(CurrentUserReaction)
  • common/types/types.ts
types.ts
export type Tag = z.infer<typeof tagSchema>
export type User = z.infer<typeof userSchema>
export type Cover = z.infer<typeof coverSchema>
export type Images = z.infer<typeof imagesSchema>
export type CurrentUserReaction = z.infer<typeof currentUserReactionSchema>
  • common/enums/enums.ts
enums.ts
export const CurrentUserReaction = {
  Like: 1,
  Dislike: -1,
  None: 0,
} as const
 
// ❌ remove
// export type CurrentUserReaction = (typeof CurrentUserReaction)[keyof typeof CurrentUserReaction]
  • playlists.schemas.ts
playlists.schemas.ts
export const createPlaylistSchema = z.object({
  title: z
    .string()
    .min(1, 'The title length must be more than 1 character')
    .max(100, 'The title length must be less than 100 characters'),
  description: z.string().max(1000, 'The description length must be less than 1000 characters.'),
})
 
export const playlistMetaSchema = z.object({
  page: z.int().positive(),
  pageSize: z.int().positive(),
  totalCount: z.int().positive(),
  pagesCount: z.int().positive(),
})
 
export const playlistAttributesSchema = z.object({
  title: z.string(),
  description: z.string(),
  addedAt: z.iso.datetime(),
  updatedAt: z.iso.datetime(),
  order: z.int(),
  dislikesCount: z.int().nonnegative(),
  likesCount: z.int().nonnegative(),
  tags: z.array(tagSchema),
  images: imagesSchema,
  user: userSchema,
  currentUserReaction: currentUserReactionSchema,
})
 
export const playlistDataSchema = z.object({
  id: z.string(),
  type: z.literal('playlists'),
  attributes: playlistAttributesSchema,
})
 
export const playlistsResponseSchema = z.object({
  data: z.array(playlistDataSchema),
  meta: playlistMetaSchema,
})
  • playlistsApi.types.ts
playlistsApi.types.ts
export type PlaylistMeta = z.infer<typeof playlistMetaSchema>
export type PlaylistAttributes = z.infer<typeof playlistAttributesSchema>
export type PlaylistData = z.infer<typeof playlistDataSchema>
export type PlaylistsResponse = z.infer<typeof playlistsResponseSchema>
 
// Arguments
export type FetchPlaylistsArgs = {
  pageNumber?: number
  pageSize?: number
  search?: string
  sortBy?: 'addedAt' | 'likesCount'
  sortDirection?: 'asc' | 'desc'
  tagsIds?: string[]
  userId?: string
  trackId?: string
}
 
export type CreatePlaylistArgs = z.infer<typeof createPlaylistSchema>
 
export type UpdatePlaylistArgs = {
  title: string
  description: string
  tagIds: string[]
}

fetchPlaylists validation

🔗

Начиная с версии 2.7.0 от 16.04.2025 RTK query добавили Standard Schema Validation

Если мы используем responseSchema, то типизировать ответ не надо, он автоматически подтянется

playlistsApi.ts
export const playlistsApi = baseApi.injectEndpoints({
  endpoints: build => ({
    fetchPlaylists: build.query({
      query: (params: FetchPlaylistsArgs) => ({ url: `playlists`, params }),
      responseSchema: playlistsResponseSchema,
      catchSchemaFailure: err => {
        errorToast('Zod error. Details in the console', err.issues)
        return { status: 'CUSTOM_ERROR', error: 'Schema validation failed' }
      },
      providesTags: ['Playlist'],
    }),
    /*...*/
  }),
})

Специально допустим ошибку, чтобы проверить работоспособность.

playlistMetaSchema.ts
export const playlistMetaSchema = z.object({
  // page: z.int().positive(),
  page: z.string(),
  pageSize: z.int().positive(),
  totalCount: z.int().positive(),
  pagesCount: z.int().positive(),
})

Результат:

  • Плейлисты не подгрузятся пока не будет устранена ошибка zod валидации 🚀
  • Ошибка выведется на экран 🚀
  • В консоли можно увидеть детали ошибки 🚀

createPlaylist validation

По аналогии сделаем zod валидацию для создания плейлиста

  • playlists.schemas.ts
playlists.schemas.ts
/*...*/
export const playlistCreateResponseSchema = z.object({
  data: playlistDataSchema,
})
playlistsApi.ts
export const playlistsApi = baseApi.injectEndpoints({
  endpoints: build => ({
    createPlaylist: build.mutation({
      query: (body: CreatePlaylistArgs) => ({ url: 'playlists', method: 'post', body }),
      responseSchema: playlistCreateResponseSchema,
      catchSchemaFailure: err => {
        errorToast('Zod error. Details in the console', err.issues)
        return { status: 'CUSTOM_ERROR', error: 'Schema validation failed' }
      },
      invalidatesTags: ['Playlist'],
    }),
    /*...*/
  }),
})

Результат: допустите ошибку в playlistCreateResponseSchema и убедитесь, что валидация отработает верно 🚀

uploadPlaylistCover validation

Чтобы завершить playlistApi, реализуем еще zod валидацию для обновления обложки плейлиста

playlistsApi.ts
export const playlistsApi = baseApi.injectEndpoints({
  endpoints: build => ({
    uploadPlaylistCover: build.mutation({
      query: ({ playlistId, file }: { playlistId: string; file: File }) => {
        /*...*/
      },
      responseSchema: imagesSchema,
      catchSchemaFailure: err => {
        errorToast('Zod error. Details in the console', err.issues)
        return { status: 'CUSTOM_ERROR', error: 'Schema validation failed' }
      },
      invalidatesTags: ['Playlist'],
    }),
    /*...*/
  }),
})
⚠️

Если бэкенд ничего не возвращает, значит и валидировать ничего не нужно. Соответственно эндпоинты deletePlaylist, updatePlaylist и deletePlaylistCover оставляем как есть.

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

withZodCatch

Обратите внимание, что теперь нам в каждом эндпоинте где мы хотим сделать валидацию придется дублировать catchSchemaFailure

playlistsApi.ts
export const playlistsApi = baseApi.injectEndpoints({
  endpoints: build => ({
    fetchPlaylists: build.query({
      query: (params: FetchPlaylistsArgs) => ({ url: `playlists`, params }),
      responseSchema: playlistsResponseSchema,
      catchSchemaFailure: err => {
        errorToast('Zod error. Details in the console', err.issues)
        return { status: 'CUSTOM_ERROR', error: 'Schema validation failed' }
      },
      providesTags: ['Playlist'],
    }),
    createPlaylist: build.mutation({
      query: (body: CreatePlaylistArgs) => ({ url: 'playlists', method: 'post', body }),
      responseSchema: playlistCreateResponseSchema,
      catchSchemaFailure: err => {
        errorToast('Zod error. Details in the console', err.issues)
        return { status: 'CUSTOM_ERROR', error: 'Schema validation failed' }
      },
      invalidatesTags: ['Playlist'],
    }),
    uploadPlaylistCover: build.mutation({
      query: ({ playlistId, file }: { playlistId: string; file: File }) => {
        /*...*/
      },
      responseSchema: imagesSchema,
      catchSchemaFailure: err => {
        errorToast('Zod error. Details in the console', err.issues)
        return { status: 'CUSTOM_ERROR', error: 'Schema validation failed' }
      },
      invalidatesTags: ['Playlist'],
    }),
    /*...*/
  }),
})

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

  • common/utils/withZodCatch.ts
withZodCatch.ts
import { errorToast } from '@/common/utils/errorToast.ts'
import { type FetchBaseQueryError, NamedSchemaError } from '@reduxjs/toolkit/query/react'
import type { ZodType } from 'zod'
 
export const withZodCatch = <T extends ZodType>(schema: T) => ({
  responseSchema: schema,
  catchSchemaFailure: (err: NamedSchemaError): FetchBaseQueryError => {
    errorToast('Zod error. Details in the console', err.issues)
    return {
      status: 'CUSTOM_ERROR',
      error: 'Schema validation failed',
    }
  },
})
  • playlistsApi.ts
playlistsApi.ts
export const playlistsApi = baseApi.injectEndpoints({
  endpoints: build => ({
    fetchPlaylists: build.query({
      query: (params: FetchPlaylistsArgs) => ({ url: `playlists`, params }),
      ...withZodCatch(playlistsResponseSchema),
      providesTags: ['Playlist'],
    }),
    createPlaylist: build.mutation({
      query: (body: CreatePlaylistArgs) => ({ url: 'playlists', method: 'post', body }),
      ...withZodCatch(playlistCreateResponseSchema),
      invalidatesTags: ['Playlist'],
    }),
    uploadPlaylistCover: build.mutation({
      query: ({ playlistId, file }: { playlistId: string; file: File }) => {
        /*...*/
      },
      ...withZodCatch(imagesSchema),
      invalidatesTags: ['Playlist'],
    }),
    /*...*/
  }),
})

Результат: вынесли общую логику в утилитную функцию withZodCatch 🚀

auth

Очень рекомендуем сделать по аналогии самостоятельно, а потом свериться с нашим решением.

  • auth/model/auth.schemas.ts
auth.schemas.ts
import * as z from 'zod'
 
export const meResponseSchema = z.object({
  userId: z.string(),
  login: z.string(),
})
 
export const loginResponseSchema = z.object({
  refreshToken: z.jwt(),
  accessToken: z.jwt(),
})
  • authApi.types.ts
authApi.types.ts
import { loginResponseSchema, type meResponseSchema } from '@/features/auth/model/auth.schemas.ts'
import * as z from 'zod'
 
export type MeResponse = z.infer<typeof meResponseSchema>
export type LoginResponse = z.infer<typeof loginResponseSchema>
 
// Arguments
export type LoginArgs = {
  code: string
  redirectUri: string
  rememberMe: boolean
  accessTokenTTL?: string // e.g. "3m"
}
  • authApi.ts
authApi.ts
export const authApi = baseApi.injectEndpoints({
  endpoints: build => ({
    getMe: build.query({
      query: () => `auth/me`,
      ...withZodCatch(meResponseSchema),
      providesTags: ['Auth'],
    }),
    login: build.mutation({
      query: (payload: LoginArgs) => ({
        url: `auth/login`,
        method: 'post',
        body: { ...payload, accessTokenTTL: '3m' },
      }),
      ...withZodCatch(loginResponseSchema),
      /*...*/
    }),
    /*...*/
  }),
})

Результат: покрыли zod валидацией все ответы от сервера для auth 🚀

tracks

Очень рекомендуем сделать по аналогии по аналогии самостоятельно, а потом свериться с нашим решением.

  • tracks/model/tracks.schemas.ts
tracks.schemas.ts
import * as z from 'zod'
import { imagesSchema, currentUserReactionSchema, userSchema } from '@/common/schemas'
 
export const trackAttachmentSchema = z.object({
  id: z.string(),
  addedAt: z.iso.datetime(),
  updatedAt: z.iso.datetime(),
  version: z.int().nonnegative(),
  url: z.url(),
  contentType: z.string(),
  originalName: z.string(),
  fileSize: z.int().nonnegative(),
})
 
export const trackRelationshipsSchema = z.object({
  artists: z.object({
    data: z.array(
      z.object({
        id: z.string(),
        type: z.literal('artists'),
      })
    ),
  }),
})
 
export const trackAttributesSchema = z.object({
  title: z.string(),
  addedAt: z.iso.datetime(),
  attachments: z.array(trackAttachmentSchema),
  images: imagesSchema,
  currentUserReaction: currentUserReactionSchema,
  user: userSchema,
  isPublished: z.boolean(),
  publishedAt: z.iso.datetime(),
})
 
export const tracksMetaSchema = z.object({
  nextCursor: z.string().nullable(),
  page: z.int().positive(),
  pageSize: z.int().positive(),
  totalCount: z.int().positive().nullable(),
  pagesCount: z.int().positive().nullable(),
})
 
export const tracksIncludedSchema = z.object({
  id: z.string(),
  type: z.literal('artists'),
  attributes: z.object({
    name: z.string(),
  }),
})
 
export const trackDataSchema = z.object({
  id: z.string(),
  type: z.literal('tracks'),
  attributes: trackAttributesSchema,
  relationships: trackRelationshipsSchema,
})
 
export const fetchTracksResponseSchema = z.object({
  data: z.array(trackDataSchema),
  included: z.array(tracksIncludedSchema),
  meta: tracksMetaSchema,
})
  • tracksApi.types.ts
tracksApi.types.ts
export type TrackAttachment = z.infer<typeof trackAttachmentSchema>
export type TrackRelationships = z.infer<typeof trackRelationshipsSchema>
export type TrackAttributes = z.infer<typeof trackAttributesSchema>
export type TrackData = z.infer<typeof trackDataSchema>
export type TracksIncluded = z.infer<typeof tracksIncludedSchema>
export type TracksMeta = z.infer<typeof tracksMetaSchema>
export type FetchTracksResponse = z.infer<typeof fetchTracksResponseSchema>
 
// 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
}

withZodCatch добавьте самостоятельно

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

skipschemavalidation

К минусам использования zod часто относят следующее:

1. Оверхед на продакшене

  • Валидация на клиенте и сервере требует ресурсов (CPU и память).

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

  • Иногда на продакшене разработчики предпочитают минимизировать валидацию (особенно если все данные уже проверяются на уровне БД или API).

2. Размер бандла (для фронтенда)

  • Zod добавляет в проект десятки килобайт к бандлу.

  • Для SPA или PWA это может быть заметным, особенно если использовать его повсюду.

Поэтому если вышеперечисленные аргументы для играют ключевую роль, то в production zod можно отключить при помощи skipschemavalidation

Глобальное использование:

baseApi.ts
export const baseApi = createApi({
  reducerPath: 'baseApi',
  tagTypes: ['Playlist', 'Auth'],
  endpoints: () => ({}),
  baseQuery: baseQueryWithReauth,
  skipSchemaValidation: process.env.NODE_ENV === 'production',
})

Локальное использование (для отдельных эндпоинтов):

playlistsApi
export const playlistsApi = baseApi.injectEndpoints({
  endpoints: build => ({
    fetchPlaylists: build.query({
      query: (params: FetchPlaylistsArgs) => ({ url: `playlists`, params }),
      ...withZodCatch(playlistsResponseSchema),
      skipSchemaValidation: process.env.NODE_ENV === 'production',
      providesTags: ['Playlist'],
    }),
  }),
})

Результат: теперь вы знаете как гибко контролировать zod 🚀


🔶 16. Streaming update (websockets)

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

В отличие от HTTP‑запросов, где клиент инициирует каждый запрос, при WebSocket соединении обе стороны могут в реальном времени отправлять и получать данные, без необходимости заново открывать соединение.

Простой пример:

  • HTTP: клиент → сервер → ответ → соединение закрывается.
  • WebSocket: клиент ↔ сервер (открыто постоянное соединение, и обе стороны могут обмениваться сообщениями, когда угодно).

Playlist created

Когда мы находимся на странице публичных плейлистов, то любой пользователь может создать новый плейлист. И хотелось бы об этом знать сразу же и без перезагрузки увидеть новый плейлист. Для этого будем использовать библиотеку socket.io

Socket.IO — это библиотека для реального времени, которая использует WebSocket и fallback‑технологии, предоставляя удобный API для двусторонней связи между клиентом и сервером.

Установим библиотеку socket.io-client

Terminal
pnpm add socket.io-client

Реализуем нашу задачу согласно документации

  • playlistsApi.types.ts
playlistsApi.types.ts
// WebSocket Events
export type PlaylistCreatedEvent = {
  type: 'tracks.playlist-created'
  payload: {
    data: PlaylistData
  }
}
  • playlistsApi.ts
playlistsApi.ts
export const playlistsApi = baseApi.injectEndpoints({
  endpoints: build => ({
    fetchPlaylists: build.query({
      query: (params: FetchPlaylistsArgs) => ({ url: `playlists`, params }),
      ...withZodCatch(playlistsResponseSchema),
      keepUnusedDataFor: 0, // 👈 очистка сразу после размонтирования
      async onCacheEntryAdded(_arg, { updateCachedData, cacheDataLoaded, cacheEntryRemoved }) {
        // Ждем разрешения начального запроса перед продолжением
        await cacheDataLoaded
 
        // Создаем Socket.IO соединение с сервером
        const socket: Socket = io('https://musicfun.it-incubator.app', {
          path: '/api/1.0/ws', // пользовательский путь для Socket.IO сервера (по умолчанию '/socket.io/')
          transports: ['websocket'],
        })
 
        socket.on('connect', () => console.log('✅ Подключен к серверу'))
 
        socket.on('tracks.playlist-created', (msg: PlaylistCreatedEvent) => {
          // 1 вариант
          const newPlaylist = msg.payload.data
          updateCachedData(state => {
            state.data.pop()
            state.data.unshift(newPlaylist)
            state.meta.totalCount = state.meta.totalCount + 1
            state.meta.pagesCount = Math.ceil(state.meta.totalCount / state.meta.pageSize)
          })
          // 2 вариант
          // dispatch(playlistsApi.util.invalidateTags(['Playlist']))
        })
 
        // CacheEntryRemoved разрешится, когда подписка на кеш больше не активна
        await cacheEntryRemoved
        // Выполняем шаги очистки после разрешения промиса `cacheEntryRemoved`
        socket.on('disconnect', () => console.log('❌ Соединение разорвано'))
      },
      providesTags: ['Playlist'],
    }),
    /*...*/
  }),
})

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

Вынесение логики

Если нам будет необходимо внедрить вебсокеты для другого события, то нам придется дублировать код и открывать несколько соединений, поэтому сразу зарефакторим и декомпозируем код

  • .env
.env
VITE_BASE_URL=https://musicfun.it-incubator.app/api/1.0
VITE_DOMAIN_ADDRESS=http://localhost:3000
VITE_SOCKET_URL=https://musicfun.it-incubator.app
VITE_API_KEY=
  • common/constants.ts
constants.ts
/*...*/
 
export const SOCKET_EVENTS = {
  TRACK_PUBLISHED: 'tracks.track-published',
  TRACK_ADDED_TO_PLAYLIST: 'tracks.track-added-to-playlist',
  TRACK_LIKED: 'tracks.track-liked',
  TRACK_IMAGE_PROCESSED: 'tracks.track-image-processed',
  PLAYLIST_IMAGE_PROCESSED: 'tracks.playlist-image-processed',
  PLAYLIST_CREATED: 'tracks.playlist-created',
  PLAYLIST_UPDATED: 'tracks.playlist-updated',
} as const
 
export type SocketEvents = (typeof SOCKET_EVENTS)[keyof typeof SOCKET_EVENTS]

Вынесем подключение сокета в общий модуль

  • common/socket/getSocket.ts
getSocket.ts
import { io, Socket } from 'socket.io-client'
 
let socket: Socket | null = null
 
export const getSocket = (): Socket => {
  if (!socket) {
    socket = io(import.meta.env.VITE_SOCKET_URL, {
      path: '/api/1.0/ws',
      transports: ['websocket'],
    })
 
    socket.on('connect', () => console.log('✅ Connected to server'))
    socket.on('disconnect', () => console.log('❌ Disconnected from server'))
  }
  return socket
}

Сделаем универсальный подписчик на события

  • common/socket/subscribeToEvent.ts
subscribeToEvent.ts
import type { SocketEvents } from '@/common/constants'
import { getSocket } from './getSocket.ts'
import type { Socket } from 'socket.io-client'
 
type Callback<T> = (data: T) => void
 
export const subscribeToEvent = <T>(event: SocketEvents, callback: Callback<T>) => {
  const socket: Socket = getSocket()
  socket.on(event, callback)
 
  return () => {
    socket.off(event, callback)
  }
}

Внедрим код в playlistsApi.ts

playlistsApi.ts
export const playlistsApi = baseApi.injectEndpoints({
  endpoints: build => ({
    fetchPlaylists: build.query({
      query: (params: FetchPlaylistsArgs) => ({ url: `playlists`, params }),
      ...withZodCatch(playlistsResponseSchema),
      keepUnusedDataFor: 0, // 👈 очистка сразу после размонтирования
      async onCacheEntryAdded(_arg, { updateCachedData, cacheDataLoaded, cacheEntryRemoved }) {
        // Ждем разрешения начального запроса перед продолжением
        await cacheDataLoaded
 
        const unsubscribe = subscribeToEvent<PlaylistCreatedEvent>(
          SOCKET_EVENTS.PLAYLIST_CREATED,
          msg => {
            const newPlaylist = msg.payload.data
            updateCachedData(state => {
              state.data.pop()
              state.data.unshift(newPlaylist)
              state.meta.totalCount = state.meta.totalCount + 1
              state.meta.pagesCount = Math.ceil(state.meta.totalCount / state.meta.pageSize)
            })
          }
        )
 
        // CacheEntryRemoved разрешится, когда подписка на кеш больше не активна
        await cacheEntryRemoved
        unsubscribe()
      },
      providesTags: ['Playlist'],
    }),
    /*...*/
  }),
})

Результат: зарефакторили код 🚀

Playlist updated

Реализуем прослушивание еще одного события: обновление плейлиста

  • playlistsApi.types.ts
playlistsApi.types.ts
// WebSocket Events
/*...*/
 
export type PlaylistUpdatedEvent = {
  type: 'tracks.playlist-updated'
  payload: {
    data: PlaylistData
  }
}
  • playlistsApi.ts
playlistsApi.ts
export const playlistsApi = baseApi.injectEndpoints({
  endpoints: build => ({
    fetchPlaylists: build.query({
      query: (params: FetchPlaylistsArgs) => ({ url: `playlists`, params }),
      ...withZodCatch(playlistsResponseSchema),
      keepUnusedDataFor: 0, // 👈 очистка сразу после размонтирования
      async onCacheEntryAdded(_arg, { updateCachedData, cacheDataLoaded, cacheEntryRemoved }) {
        // Ждем разрешения начального запроса перед продолжением
        await cacheDataLoaded
 
        const unsubscribe = subscribeToEvent<PlaylistCreatedEvent>(
          SOCKET_EVENTS.PLAYLIST_CREATED,
          msg => {
            const newPlaylist = msg.payload.data
            updateCachedData(state => {
              state.data.pop()
              state.data.unshift(newPlaylist)
              state.meta.totalCount = state.meta.totalCount + 1
              state.meta.pagesCount = Math.ceil(state.meta.totalCount / state.meta.pageSize)
            })
          }
        )
 
        const unsubscribe2 = subscribeToEvent<PlaylistUpdatedEvent>(
          SOCKET_EVENTS.PLAYLIST_UPDATED,
          msg => {
            const newPlaylist = msg.payload.data
            updateCachedData(state => {
              const index = state.data.findIndex(playlist => playlist.id === newPlaylist.id)
              if (index !== -1) {
                state.data[index] = { ...state.data[index], ...newPlaylist }
              }
            })
          }
        )
 
        // CacheEntryRemoved разрешится, когда подписка на кеш больше не активна
        await cacheEntryRemoved
        unsubscribe()
        unsubscribe2()
      },
      providesTags: ['Playlist'],
    }),
    /*...*/
  }),
})

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

Массив отписок

Для того чтобы сделать работу с подписками и отписками чище и более масштабируемо будем использовать массив отписок.

Будем использовать простейший способ — хранить отписки в массиве и потом пробегаться по ним при cleanup:

  • playlistsApi.ts
playlistsApi.ts
export const playlistsApi = baseApi.injectEndpoints({
  endpoints: build => ({
    fetchPlaylists: build.query({
      query: (params: FetchPlaylistsArgs) => ({ url: `playlists`, params }),
      ...withZodCatch(playlistsResponseSchema),
      keepUnusedDataFor: 0, // 👈 очистка сразу после размонтирования
      async onCacheEntryAdded(_arg, { updateCachedData, cacheDataLoaded, cacheEntryRemoved }) {
        // Ждем разрешения начального запроса перед продолжением
        await cacheDataLoaded
 
        const unsubscribes = [
          subscribeToEvent<PlaylistCreatedEvent>(SOCKET_EVENTS.PLAYLIST_CREATED, msg => {
            const newPlaylist = msg.payload.data
            updateCachedData(state => {
              state.data.pop()
              state.data.unshift(newPlaylist)
              state.meta.totalCount = state.meta.totalCount + 1
              state.meta.pagesCount = Math.ceil(state.meta.totalCount / state.meta.pageSize)
            })
          }),
          subscribeToEvent<PlaylistUpdatedEvent>(SOCKET_EVENTS.PLAYLIST_UPDATED, msg => {
            const newPlaylist = msg.payload.data
            updateCachedData(state => {
              const index = state.data.findIndex(playlist => playlist.id === newPlaylist.id)
              if (index !== -1) {
                state.data[index] = { ...state.data[index], ...newPlaylist }
              }
            })
          }),
        ]
 
        // CacheEntryRemoved разрешится, когда подписка на кеш больше не активна
        await cacheEntryRemoved
        unsubscribes.forEach(unsubscribe => unsubscribe())
      },
      providesTags: ['Playlist'],
    }),
    /*...*/
  }),
})

Результат:

  • Чисто и читаемо 🚀
  • Можно легко добавлять новые подписки 🚀
  • Не нужно плодить unsubscribe1, unsubscribe2 и т.д. 🚀