RTK Query Полный курс, часть 3 — Loaders, Error Handling, Optimistic Update

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

🔶 10. Loaders

Query Loading State

isLoading vs isFetching

Автоматически сгенерированные RTK Query хуки предоставляют производные булевые флаги Query Loading State, которые отражают текущее состояние данного запроса:

  1. isLoading будет true, когда запрос выполняется впервые для данного хука и данные ещё недоступны. Используется для отображения Skeleton или пустой заглушки, пока данные загружаются впервые.

  2. isFetching будет true, когда запрос выполняется для заданного эндпоинта и параметров запроса, но не обязательно в первый раз. Используется для показа текущих данных с добавлением визуального индикатора обновления данных (LinearProgress).

Выведем в консоль isLoading, isFetching в компоненте PlaylistsPage.tsx

PlaylistsPage.tsx
export const PlaylistsPage = () => {
  /*...*/
 
  const { data, isLoading, isFetching } = useFetchPlaylistsQuery({
    search: debounceSearch,
    pageNumber: currentPage,
    pageSize,
  })
 
  console.log({ isLoading, isFetching })
 
  /*...*/
}

При первой загрузке isFetching и isLoading отрабатывают одинаково:

console
{isLoading: true, isFetching: true} // запрос в процессе выполнения
{isLoading: false, isFetching: false} // запрос завершен

При переходе на другую страницу (endpoint тот же, меняется только query-параметр page) isFetching реагирует на загрузку данных, а isLoading нет:

console
{isLoading: false, isFetching: true} // запрос в процессе выполнения
{isLoading: false, isFetching: false} // запрос завершен

Локальный loader

Чтобы при разработке видеть лоадер, сделаем искусственную задержку в 2 секунды

baseApi.ts
export const baseApi = createApi({
  reducerPath: 'baseApi',
  tagTypes: ['Playlist'],
  baseQuery: async (args, api, extraOptions) => {
    await new Promise(resolve => setTimeout(resolve, 2000)) // delay
 
    return 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)
  },
  endpoints: () => ({}),
})

При первой подгрузке будем использовать свойство isLoading и показывать пользователю skeleton.

🔗

Сейчас скелетон мы не будем реализовывать, т.к. на верстку сейчас мы акцент не делаем. Но в качестве домашнего задания можете реализовать самостоятельно используя библиотеку react loading skeleton

А при пагинации свойство isFetching и отображать LinearProgress

PlaylistsPage.tsx
export const PlaylistsPage = () => {
  /*...*/
 
  if (isLoading) return <h1>Skeleton loader...</h1>
 
  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} />
      {isFetching && <LinearProgress />}
      {/*...*/}
    </div>
  )
}
  • common/components/LinearProgress/LinearProgress.tsx
LinearProgress.tsx
import s from './LinearProgress.module.css'
 
type Props = {
  height?: number
}
 
export const LinearProgress = ({ height = 4 }: Props) => {
  return (
    <div className={s.root} style={{ height }}>
      <div className={`${s.bar} ${s.indeterminate1}`} />
      <div className={`${s.bar} ${s.indeterminate2}`} />
    </div>
  )
}
  • common/components/LinearProgress/LinearProgress.module.css
LinearProgress.module.css
.root {
  background: #23272f;
  border-radius: 8px;
  overflow: hidden;
  position: relative;
  width: 100%;
}
 
.bar {
  height: 100%;
  background: #ddd;
  position: absolute;
}
 
@keyframes indeterminate1 {
  0% {
    left: -35%;
    right: 100%;
  }
  60% {
    left: 100%;
    right: -90%;
  }
  100% {
    left: 100%;
    right: -90%;
  }
}
 
@keyframes indeterminate2 {
  0% {
    left: -200%;
    right: 100%;
  }
  60% {
    left: 107%;
    right: -8%;
  }
  100% {
    left: 107%;
    right: -8%;
  }
}
 
.indeterminate1 {
  animation: indeterminate1 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395) infinite;
}
 
.indeterminate2 {
  animation: indeterminate2 2.1s cubic-bezier(0.165, 0.84, 0.44, 1) infinite;
}

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

Глобальный loader

Мы реализовали отображение загрузки только для получения плейлистов. Но, нам же нужно показывть индикатор загрузки при добавлении / удалении/ редактировании плейлиста. А в дальнейшем все CRUD операции для других страниц.

Соответственно хотелось бы иметь какой-то глобальный индикатор загрузки.

Существует несколько вариантов, чтобы реализовать такой loader:

  • написать кастомный middleware
  • создать slice и в нем использовать builder.addMatcher
  • написать кастомный хук useGlobalLoading

Остановимся на самом простом, но рабочем варианте. Напишем кастомный хук useGlobalLoading

useGlobalLoading

  • common/hooks/useGlobalLoading.ts
useGlobalLoading.ts
import type { RootState } from '@/app/model/store.ts'
import { useSelector } from 'react-redux'
 
export const useGlobalLoading = () => {
  return useSelector((state: RootState) => {
    // Получаем все активные запросы из RTK Query API
    const queries = Object.values(state.baseApi.queries || {})
    const mutations = Object.values(state.baseApi.mutations || {})
 
    // Проверяем, есть ли активные запросы (статус 'pending')
    const hasActiveQueries = queries.some(query => query?.status === 'pending')
    const hasActiveMutations = mutations.some(mutation => mutation?.status === 'pending')
 
    return hasActiveQueries || hasActiveMutations
  })
}
  • store.ts
store.ts
export const store = configureStore({
  /*...*/
})
 
export type RootState = ReturnType<typeof store.getState>
  • App.tsx
App.tsx
export const App = () => {
  const isGlobalLoading = useGlobalLoading()
 
  return (
    <>
      <Header />
      {isGlobalLoading && <LinearProgress />}
      <div className={s.layout}>
        <Routing />
      </div>
      <ToastContainer />
    </>
  )
}
⚠️

Из компонента PlaylistsPage.tsx удалите isFetching, т.к. все запросы теперь обрабатываются глобально

Результат: теперь при каждом запросе будет отображаться LinearProgress 🚀

Исключения эндпоинтов

Если для некоторых эндпоинтов мы хотим сделать исключения, то просто будем добавлять их в массив excludedEndpoints

useGlobalLoading.ts
// Список эндпоинтов для исключения из глобального индикатора
const excludedEndpoints = [
  playlistsApi.endpoints.fetchPlaylists.name,
  tracksApi.endpoints.fetchTracks.name,
]
 
export const useGlobalLoading = () => {
  return useSelector((state: RootState) => {
    // Получаем все активные запросы из RTK Query API
    const queries = Object.values(state.baseApi.queries || {})
    const mutations = Object.values(state.baseApi.mutations || {})
 
    const hasActiveQueries = queries.some(query => {
      if (query?.status !== 'pending') return
      if (excludedEndpoints.includes(query.endpointName)) {
        const completedQueries = queries.filter(q => q?.status === 'fulfilled')
        return completedQueries.length > 0
      }
    })
 
    const hasActiveMutations = mutations.some(mutation => mutation?.status === 'pending')
 
    return hasActiveQueries || hasActiveMutations
  })
}

Плюсы такого подхода:

  • Type Safety -- TypeScript проверит, что endpoint существует 🚀
  • Refactoring Safety -- если переименуете endpoint, изменения подтянутся автоматически 🚀
  • Читаемость -- сразу понятно, какой именно endpoint исключается 🚀

🔶 11. Обработка ошибок

В RTK Query обработка ошибок может выполняться для каждого запроса и на глобальном уровне:

Обработка ошибок на уровне useQuery и useMutation

RTK Query возвращает объект результата, который содержит значения error и isError, которые можно использовать в компоненте для отображения и обработки ошибок:

PlaylistsPage.tsx
export const PlaylistsPage = () => {
  const { data, isLoading, error, isError } = useFetchPlaylistsQuery({
    search: debounceSearch,
    pageNumber: currentPage,
    pageSize,
  })
 
  if (error) {
    console.log(error)
  }
 
  /*...*/
}

В playlistApi, для демонстрации, сломаем URL. Например, вот так:

playlistApi.ts
  fetchPlaylists: build.query<PlaylistsResponse, FetchPlaylistsArgs>({
      query: (params) => ({ url: `playlists2`, params }),
      providesTags: ['Playlist'],
    }),

В консоли увидим следующую ошибку:

console
{
  "status": 404,
  "data": {
    "error": "Not Found",
    "message": "Cannot GET /api/1.0/playlists2?search=&pageNumber=1&pageSize=2",
    "statusCode": 404
  }
}

Можно было бы написать обработку таким образом:

PlaylistsPage.tsx
export const PlaylistsPage = () => {
  const { data, isLoading, error } = useFetchPlaylistsQuery({
    search: debounceSearch,
    pageNumber: currentPage,
    pageSize,
  })
 
  if (error) {
    toast(error.data.error, { type: 'error', theme: 'colored' })
  }
 
  /*...*/
}

Но этот вариант не сработает из-за TS. Согласно документации в RTK Query есть несколько типов ошибки.

FetchBaseQueryError

Когда ошибка корректно возвращается из базового запроса (base query), RTK Query предоставит эту ошибку напрямую

  • 'FETCH_ERROR' - ошибка сети
  • 'PARSING_ERROR' - ошибка во время парсинга
  • 'CUSTOM_ERROR' - пользовательский тип ошибки
  • 'HTTP status code' - status code ошибка (401, 403, 500 и т.д.)

SerializedError

Если пользовательский код выбросит непредвиденную ошибку (например, throw new Error). В таком случае ошибка будет преобразована в формат SerializedError.

Локальный вывод ошибки

  • PlaylistsPage.tsx
PlaylistsPage.tsx
export const PlaylistsPage = () => {
  const { data, isLoading, error } = useFetchPlaylistsQuery({
    search: debounceSearch,
    pageNumber: currentPage,
    pageSize,
  })
 
  useEffect(() => {
    if (!error) return
    if ('status' in error) {
      // FetchBaseQueryError
      const errMsg = 'error' in error ? error.error : (error.data as { error: string }).error
      toast(errMsg, { type: 'error', theme: 'colored' })
    } else {
      // SerializedError
      toast(error.message || 'Some error occurred', { type: 'error', theme: 'colored' })
    }
  }, [error])
 
  /*...*/
}

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

Если сейчас изменить значение 'API-KEY', то уже будет статус код 429 и ошибку нужно будет обрабатывать по-другому

console
{
  "status": 429,
  "data": {
    "message": "API key is invalid or not passed. (code=2) ",
    "statusCode": 429
  }
}

Если писать в лоб, то будет вот так

PlaylistsPage.tsx
useEffect(() => {
  if (!error) return
  if ('status' in error) {
    // FetchBaseQueryError
    const errMsg =
      'error' in error
        ? error.error
        : (
            error.data as {
              error: string
            }
          ).error ||
          (error.data as { message: string }).message ||
          'Some error occurred'
    toast(errMsg, { type: 'error', theme: 'colored' })
  } else {
    // SerializedError
    toast(error.message || 'Some error occurred', { type: 'error', theme: 'colored' })
  }
}, [error])

Результат: у нас получилось обработать ошибки, но:

  1. У нас получился загроможденный логикой компонент 😢
  2. Логика выглядит довольно громоздкой и плохо читаемой 😢
  3. Примерно такую же логику нам придется писать по всему приложению, во всех квери запросах и мутациях 😢

Поэтому давайте сразу будем делать по-другому и обрабатывать ошибки глобально 🚀

Глобальная обработка ошибок с помощью baseQuery

Для глобальной обработки ошибок для всех запросов используется кастомный baseQuery, позволяющий перехватывать ошибки на уровне API.

Status codes (404, 429)

baseApi.ts
export const baseApi = createApi({
  reducerPath: 'baseApi',
  tagTypes: ['Playlist'],
  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 + 'abc',
      },
      prepareHeaders: headers => {
        headers.set('Authorization', `Bearer ${import.meta.env.VITE_ACCESS_TOKEN}`)
        return headers
      },
    })(args, api, extraOptions)
 
    if (result.error) {
      switch (result.error.status) {
        case 404:
          toast((result.error.data as { error: string }).error, { type: 'error', theme: 'colored' })
          break
 
        case 429:
          // ✅ 1. Type Assertions
          // toast((result.error.data as { message: string }).message, { type: 'error', theme: 'colored' })
          // ✅ 2. JSON.stringify
          // toast(JSON.stringify(result.error.data), { type: 'error', theme: 'colored' })
          // ✅ 3. Type Predicate
          if (isErrorWithMessage(result.error.data)) {
            toast(result.error.data.message, { type: 'error', theme: 'colored' })
          } else {
            toast(JSON.stringify(result.error.data), { type: 'error', theme: 'colored' })
          }
          break
 
        default:
          toast('Some error occurred', { type: 'error', theme: 'colored' })
      }
    }
 
    return result
  },
  endpoints: () => ({}),
})
1. Type Assertions

Приведение типов через as (type assertion) может привести к непредвиденным ошибкам, поэтому использовать можно только если есть уверенность в типе данных:

2. JSON.stringify

Универсальный и простой в реализации вариант.

3. Type Predicate

Воспользуйтесь вспомогательной функцией isErrorWithMessage.

🔗

Хороший, но самый сложный в реализации вариант:

Создайте в src/common/utils/isErrorWithMessage.ts утилитную функцию isErrorWithMessage

src/common/utils/isErrorWithMessage.ts
/* Синтаксис error is { message: string } означает, что если функция возвращает true, TypeScript будет рассматривать
error как объект с обязательным строковым свойством message. */
export function isErrorWithMessage(error: unknown): error is { message: string } {
  return (
    typeof error === 'object' && // Проверяем, что error – это объект
    error != null && // Убеждаемся, что это не null
    'message' in error && // Проверяем, что у объекта есть свойство 'message'
    typeof (error as Record<string, unknown>).message === 'string' // Убеждаемся, что это строка
  )
}

Функция isErrorWithMessage - типовой предикат (type predicate) в TypeScript. Она проверяет, является ли переданное значение error объектом, содержащим свойство message типа string.

error is { message: string } – типовой предикат, который говорит TypeScript: "если функция возвращает true, тогда error считается объектом с полем message типа string". Без этой записи, TS будет видеть только boolean.

По аналогии сделаем isErrorWithError для 404.

Итоговый результат:

baseApi.ts
if (result.error) {
  switch (result.error.status) {
    case 404:
      if (isErrorWithError(result.error.data)) {
        toast(result.error.data.error, { type: 'error', theme: 'colored' })
      } else {
        toast(JSON.stringify(result.error.data), { type: 'error', theme: 'colored' })
      }
      break
 
    case 429:
      if (isErrorWithMessage(result.error.data)) {
        toast(result.error.data.message, { type: 'error', theme: 'colored' })
      } else {
        toast(JSON.stringify(result.error.data), { type: 'error', theme: 'colored' })
      }
      break
 
    default:
      toast('Some error occurred', { type: 'error', theme: 'colored' })
  }
}

Универсальная дженериковая функция

isErrorWithProperty.ts
export function isErrorWithProperty<T extends string>(
  error: unknown,
  property: T
): error is Record<T, string> {
  return (
    typeof error === 'object' &&
    error != null &&
    property in error &&
    typeof (error as Record<string, unknown>)[property] === 'string'
  )
}

Использование

isErrorWithProperty(result.error.data, 'error') isErrorWithProperty(result.error.data, 'message')

Результат: обработали 404 и 429 status code 🚀

Fetch, parsing, custom, timeout error

baseApi.ts
case 'FETCH_ERROR':
case 'PARSING_ERROR':
case 'CUSTOM_ERROR':
case 'TIMEOUT_ERROR':
  toast(result.error.error, { type: 'error', theme: 'colored' })
  break

Как воспроизвести ошибки

'FETCH_ERROR'

Откройте developer tools, включите offline режим и попробуйте создать плейлист

'PARSING_ERROR'

В baseApi будем выдидывать ошибку, вместо того, чтобы возвращать валидный JSON

ts
responseHandler: () => {
 throw new Error('PARSING_ERROR')
},

Результат: ошибка обработана 🚀

Status codes (403, 401, 500)

403

Чтобы воспроизвести данный статус код:

  • попробуйте удалить не свой плейлист
  • попробуйте создать больше 10 плейлистов

Тут ответ будет немного сложнее. бэкенд возвращает ошибку согласно JSON:API спецификации

isErrorWithDetailArray.ts
export function isErrorWithDetailArray(error: unknown): error is { errors: { detail: string }[] } {
  return (
    typeof error === 'object' &&
    error !== null &&
    'errors' in error &&
    Array.isArray((error as any).errors) &&
    (error as any).errors.length > 0 &&
    typeof (error as any).errors[0].detail === 'string'
  )
}
baseApi.ts
case 403:
  if (isErrorWithDetailArray(result.error.data)) {
    toast(result.error.data.errors[0].detail, { type: "error", theme: "colored" })
  } else {
    toast(JSON.stringify(result.error.data), { type: "error", theme: "colored" })
  }
break

Результат: ошибка обработана 🚀

400

Попробуйте создать плейлист с количеством символов больше 100

baseApi.ts
case 400:
case 403:
  if (isErrorWithDetailArray(result.error.data)) {
    toast(trimToMaxLength(result.error.data.errors[0].detail), { type: "error", theme: "colored" })
  } else {
    toast(JSON.stringify(result.error.data), { type: "error", theme: "colored" })
  }
break
  • common/utils/trimToMaxLength.ts
common/utils/trimToMaxLength.ts
export function trimToMaxLength(str: string, maxLength = 100): string {
  return str.length > maxLength ? str.slice(0, maxLength - 3) + '...' : str
}

401

Попробуйте передать невалидный access token. Получите ошибку авторизации. Тут все просто, данный кейс уже написан в 429

baseApi.ts
case 401:
case 429:
  if (isErrorWithProperty(result.error.data, 'message')) {
    toast(result.error.data.message, { type: 'error', theme: 'colored' })
  } else {
  toast(JSON.stringify(result.error.data), { type: 'error', theme: 'colored' })
  }
break

500-599

Пользователю опасно выводить пользователю "сырые" серверные ошибки (500–599):

  1. Утечка чувствительной информации Серверные ошибки часто содержат детали внутренней реализации: stack trace, имена таблиц, SQL-запросы, внутренние пути, названия файлов и т.д. Такая информация может быть использована злоумышленниками для атаки на систему.

  2. Подсказки для хакеров Сообщения типа Database connection failed или Invalid credentials for user admin подсказывают злоумышленникам, на каком этапе возникла проблема, и могут ускорить подбор уязвимости.

  3. Раскрытие архитектуры приложения Сообщения вида NullReferenceException in UserService или ModuleNotFoundError in utils/logger.js раскрывают детали архитектуры, что упрощает анализ системы для атакующего.

  4. Непредсказуемость содержимого ошибок Сервер может вернуть всё что угодно: приватные ключи, токены, пароли и другие критичные данные, если они случайно попадут в текст ошибки.

Как правильно

Показывать пользователю только безопасное универсальное сообщение, например: Что-то пошло не так на сервере. Пожалуйста, попробуйте позже. или Server error occurred. Please try again later.

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

baseApi.ts
default:
  if (result.error.status >= 500 && result.error.status < 600) {
    toast("Server error occurred. Please try again later.", { type: "error", theme: "colored" })
  } else {
    toast("Some error occurred", { type: "error", theme: "colored" })
  }

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

Рефакторинг

1 шаг

Чтобы очистить код baseApi от деталей, вынесите логику обработки ошибок в утилитную функцию handleErrors:

baseApi.ts
export const baseApi = createApi({
  baseQuery: async (args, api, extraOptions) => {
    const result = await fetchBaseQuery({
      /*...*/
    })(args, api, extraOptions)
 
    if (result.error) {
      handleErrors(result.error)
    }
 
    return result
  },
})
  • common/utils/handleErrors.ts
common/utils/handleErrors.ts
export const handleErrors = (error: FetchBaseQueryError) => {
  if (error) {
    switch (error.status) {
      case 'FETCH_ERROR':
      case 'PARSING_ERROR':
      case 'CUSTOM_ERROR':
      case 'TIMEOUT_ERROR':
        toast(error.error, { type: 'error', theme: 'colored' })
        break
 
      case 400:
      case 403:
        if (isErrorWithDetailArray(error.data)) {
          toast(trimToMaxLength(error.data.errors[0].detail), { type: 'error', theme: 'colored' })
        } else {
          toast(JSON.stringify(error.data), { type: 'error', theme: 'colored' })
        }
        break
 
      case 404:
        if (isErrorWithProperty(error.data, 'error')) {
          toast(error.data.error, { type: 'error', theme: 'colored' })
        } else {
          toast(JSON.stringify(error.data), { type: 'error', theme: 'colored' })
        }
        break
 
      case 401:
      case 429:
        if (isErrorWithProperty(error.data, 'message')) {
          toast(error.data.message, { type: 'error', theme: 'colored' })
        } else {
          toast(JSON.stringify(error.data), { type: 'error', theme: 'colored' })
        }
        break
 
      default:
        if (error.status >= 500 && error.status < 600) {
          toast('Server error occurred. Please try again later.', {
            type: 'error',
            theme: 'colored',
          })
        } else {
          toast('Some error occurred', { type: 'error', theme: 'colored' })
        }
    }
  }
}

2 шаг

Для отображения ошибки мы везде указываем стиль для тостера с ошибкой ({ type: "error", theme: "colored" })

Создадим 2 вспомогательные функции для тостов

  • common/utils/errorToast.ts
errorToast.ts
export const errorToast = (message: string, error?: unknown) => {
  toast(message, { theme: 'colored', type: 'error' })
 
  if (error) {
    console.error(`${message}\n`, error)
  }
}
  • common/utils/succesToast.ts
successToast.ts
export const successToast = (message: string) => {
  toast(message, { theme: 'colored', type: 'success' })
}
  • handleErrors.ts
handleErrors.ts
export const handleErrors = (error: FetchBaseQueryError) => {
  if (error) {
    switch (error.status) {
      case 'FETCH_ERROR':
      case 'PARSING_ERROR':
      case 'CUSTOM_ERROR':
      case 'TIMEOUT_ERROR':
        errorToast(error.error)
        break
 
      case 400:
      case 403:
        if (isErrorWithDetailArray(error.data)) {
          errorToast(trimToMaxLength(error.data.errors[0].detail))
        } else {
          errorToast(JSON.stringify(error.data))
        }
        break
 
      case 404:
        if (isErrorWithProperty(error.data, 'error')) {
          errorToast(error.data.error)
        } else {
          errorToast(JSON.stringify(error.data))
        }
        break
 
      case 401:
      case 429:
        if (isErrorWithProperty(error.data, 'message')) {
          errorToast(error.data.message)
        } else {
          errorToast(JSON.stringify(error.data))
        }
        break
 
      default:
        if (error.status >= 500 && error.status < 600) {
          errorToast('Server error occurred. Please try again later.', error)
        } else {
          errorToast('Some error occurred')
        }
    }
  }
}
⚠️

Замените toast на errorToast в компоненте PlaylistCover.ts

Результат: рефакторинг завершен, все ошибки обработаны 🚀


🔶 12. Optimistic update

Optimistic Update (оптимистичное обновление) - подход в разработке приложений, при котором пользовательский интерфейс (UI) обновляется до того, как сервер подтвердит успешность операции. Другими словами, приложение предполагает, что операция (например, отправка данных на сервер) завершится успешно, и сразу вносит изменения в UI. Если операция на сервере завершится неудачно, изменения в UI откатываются:

Преимущества Optimistic Update:

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

Недостатки Optimistic Update:

  • сложность реализации: нужно учитывать возможность отката изменений, если запрос завершится ошибкой;
  • потенциальная несогласованность: если сервер вернет ошибку, а приложение не сможет корректно откатить изменения, UI может остаться в некорректном состоянии;

Редактирование плейлиста

Перепишем на optimistic update редактирование плейлиста.

🔗

onQueryStarted — это часть конфигурации в RTK Query, которая позволяет вам выполнять побочные эффекты и обрабатывать запросы до того, как они будут отправлены. Эта функция особенно полезна для оптимистичного обновления состояния или для выполнения других асинхронных операций, которые могут понадобиться до завершения запроса.

🔗

updateQueryData локально обновляет данные в кэше для конкретного запроса и возвращает объект действия. При dispatch этого действия, возвращаемое значение от dispatch - объект patchResult. Если вызвать patchResult.undo(), запустится отмена изменений

🔗

queryFulfilled — это promise, возвращаемый RTK Query, который разрешается, когда запрос успешно завершен. Вы можете использовать queryFulfilled, чтобы выполнить дополнительные действия после успешного завершения запроса или для обработки ошибок, если запрос не удался.

  • playlistsApi.ts
playlistsApi.ts
export const playlistsApi = baseApi.injectEndpoints({
  endpoints: build => ({
    /*...*/
    updatePlaylist: build.mutation<void, { playlistId: string; body: UpdatePlaylistArgs }>({
      query: ({ playlistId, body }) => ({ url: `playlists/${playlistId}`, method: 'put', body }),
      async onQueryStarted({ playlistId, body }, { dispatch, queryFulfilled }) {
        const patchResult = dispatch(
          playlistsApi.util.updateQueryData(
            // название эндпоинта, в котором нужно обновить кэш
            'fetchPlaylists',
            // аргументы для эндпоинта
            { pageNumber: 1, pageSize: 2, search: '' },
            // `updateRecipe` - коллбэк для обновления закэшированного стейта мутабельным образом
            state => {
              const index = state.data.findIndex(playlist => playlist.id === playlistId)
              if (index !== -1) {
                state.data[index].attributes = { ...state.data[index].attributes, ...body }
              }
            }
          )
        )
        try {
          await queryFulfilled
        } catch {
          patchResult.undo()
        }
      },
      invalidatesTags: ['Playlist'],
    }),
  }),
})
  • EditPlaylistForm.tsx
EditPlaylistForm.ts
export const EditPlaylistForm = ({
  playlistId,
  handleSubmit,
  register,
  editPlaylist,
  setPlaylistId,
}: Props) => {
  const [updatePlaylist] = useUpdatePlaylistMutation()
 
  const onSubmit: SubmitHandler<UpdatePlaylistArgs> = data => {
    if (!playlistId) return
    updatePlaylist({ playlistId, body: data })
    setPlaylistId(null)
  }
 
  /*...*/
}

Результат:

  • при изменении плейлиста он сразу обновляется на UI 🚀
  • если запрос завершится ошибкой, тогда изменения в UI откатятся к предыдущему состоянию 🚀

selectCachedArgsForQuery

Если перейти на другую страницу плейлистов с другими query‑параметрами, оптимистичное обновление не сработает 😢

selectCachedArgsForQuery извлекает аргументы запросов, закэшированных в Redux Store. Это позволяет получить все аргументы для запросов, имеющих активные кэши. Это полезно для отслеживания того, какие запросы были сделаны и с какими параметрами, и для повторного использования этих параметров при необходимости:

playlistsApi.ts
export const playlistsApi = baseApi.injectEndpoints({
  endpoints: build => ({
    /*...*/
    updatePlaylist: build.mutation<void, { playlistId: string; body: UpdatePlaylistArgs }>({
      query: ({ playlistId, body }) => ({ url: `playlists/${playlistId}`, method: 'put', body }),
      async onQueryStarted({ playlistId, body }, { dispatch, queryFulfilled, getState }) {
        const args = playlistsApi.util.selectCachedArgsForQuery(getState(), 'fetchPlaylists')
 
        const patchResults: any[] = []
 
        args.forEach(arg => {
          patchResults.push(
            dispatch(
              playlistsApi.util.updateQueryData(
                'fetchPlaylists',
                {
                  pageNumber: arg.pageNumber,
                  pageSize: arg.pageSize,
                  search: arg.search,
                },
                state => {
                  const index = state.data.findIndex(playlist => playlist.id === playlistId)
                  if (index !== -1) {
                    state.data[index].attributes = { ...state.data[index].attributes, ...body }
                  }
                }
              )
            )
          )
        })
 
        try {
          await queryFulfilled
        } catch {
          patchResults.forEach(patchResult => {
            patchResult.undo()
          })
        }
      },
      invalidatesTags: ['Playlist'],
    }),
  }),
})

Результат: optimistic update отрабатывает с query-параметрами 🚀