Перейти к содержимому

54. RTK Query + TypeScript

TypeScript: RTK Query + TypeScript — Твой Надёжный Интерфейс к Бэкенду

Заголовок раздела «TypeScript: RTK Query + TypeScript — Твой Надёжный Интерфейс к Бэкенду»

Привет, друг! Сегодня мы погрузимся в мир, где запросы к бэкенду становятся не рутиной, а чистым удовольствием, а TypeScript обеспечивает им безопасность и предсказуемость. Речь пойдет о RTK Query — мощном инструменте из набора Redux Toolkit, который полностью меняет подход к работе с данными на фронтенде.

Представь, что у тебя есть магический щит, который не только защищает тебя от ошибок при взаимодействии с API, но и автоматически управляет кешем, состоянием загрузки, ошибками и даже инвалидирует данные после изменений. Этот щит — RTK Query, а его суперсила умножается на десять, когда он работает в связке с TypeScript. Забудь о бесконечных isLoading, isError, data состояниях, написании кастомных хуков для каждого запроса — RTK Query делает это за тебя, а TypeScript гарантирует, что ты всегда знаешь, что тебе придет и что нужно отправить.

RTK Query — это инструмент для работы с данными, построенный на базе Redux Toolkit. Он предоставляет высокоуровневые abstractions для запросов данных и управления состоянием, значительно уменьшая объем бойлерплейта и повышая производительность.

Основные преимущества:

  • Автоматическое кеширование: Запросы кешируются и повторно используются.
  • Автоматическая инвалидация: После мутаций кеш может быть инвалидирован, чтобы данные обновились.
  • Генерация хуков: Для каждого эндпоинта автоматически создаются React хуки.
  • Типизация “из коробки”: С TypeScript ты точно знаешь типы данных в запросах, ответах и состоянии.
  • Минимизация бойлерплейта: Значительно меньше кода по сравнению с ручной реализацией.

Давай посмотрим, как это работает!

Сердцем RTK Query является функция createApi. Она позволяет определить все эндпоинты твоего API, а затем генерирует все необходимое для взаимодействия с ними.

// types.ts - Определяем наши типы данных
export interface Post {
id: number;
title: string;
body: string;
userId: number;
}
export interface NewPost {
title: string;
body: string;
userId: number;
}
export interface UpdatedPost {
id: number;
title?: string;
body?: string;
userId?: number;
}
export interface PaginatedResponse<T> {
data: T[];
meta: {
totalItems: number;
currentPage: number;
itemsPerPage: number;
totalPages: number;
};
}
// api.ts - Определяем наш RTK Query API
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { Post, NewPost, UpdatedPost, PaginatedResponse } from './types'; // Импортируем наши типы
// Создаем наш API
export const postApi = createApi({
// Уникальный путь в Redux сторе для этого API.
// Это имя среза редьюсера, который будет управлять состоянием кеша.
reducerPath: 'postApi',
// Базовая функция для выполнения запросов.
// fetchBaseQuery - это обёртка над fetch, которая упрощает работу с HTTP-запросами.
baseQuery: fetchBaseQuery({ baseUrl: 'https://jsonplaceholder.typicode.com/' }),
// Теги для инвалидации кеша. Это как метки, которые помогают RTK Query понять,
// какие данные могут быть устаревшими после определенного действия.
tagTypes: ['Posts'],
// Эндпоинты - это наши конкретные запросы и мутации.
endpoints: (builder) => ({
// Пример запроса (GET) для получения всех постов
// <ТипОтвета, ТипАргументов>
getPosts: builder.query<Post[], void>({
query: () => 'posts', // Путь относительно baseUrl
// providesTags указывает, какие теги предоставляют эти данные.
// Когда данные с тегом 'Posts' инвалидируются, этот запрос будет повторно выполнен.
providesTags: (result) =>
result
? [...result.map(({ id }) => ({ type: 'Posts' as const, id })), { type: 'Posts', id: 'LIST' }]
: [{ type: 'Posts', id: 'LIST' }],
}),
// Пример запроса (GET) для получения поста по ID
getPostById: builder.query<Post, number>({
query: (id) => `posts/${id}`,
// Для одиночного поста мы предоставляем тег с его ID
providesTags: (result, error, id) => [{ type: 'Posts', id }],
}),
// Пример мутации (POST) для добавления нового поста
// <ТипОтвета, ТипАргументов>
addPost: builder.mutation<Post, NewPost>({
query: (newPost) => ({
url: 'posts',
method: 'POST',
body: newPost,
}),
// invalidatesTags указывает, какие теги должны быть инвалидированы после этой мутации.
// Это приведет к повторному запросу `getPosts`, если он активен.
invalidatesTags: [{ type: 'Posts', id: 'LIST' }],
}),
// Пример мутации (PUT/PATCH) для обновления поста
updatePost: builder.mutation<Post, UpdatedPost>({
query: ({ id, ...patch }) => ({
url: `posts/${id}`,
method: 'PUT', // Или 'PATCH' в зависимости от API
body: patch,
}),
// Инвалидируем как список, так и конкретный пост по ID
invalidatesTags: (result, error, { id }) => [
{ type: 'Posts', id },
{ type: 'Posts', id: 'LIST' }
],
}),
// Пример мутации (DELETE) для удаления поста
deletePost: builder.mutation<void, number>({
query: (id) => ({
url: `posts/${id}`,
method: 'DELETE',
}),
// Инвалидируем список постов, так как один из них был удален
invalidatesTags: (result, error, id) => [
{ type: 'Posts', id },
{ type: 'Posts', id: 'LIST' }
],
}),
}),
});
// RTK Query автоматически генерирует хуки для каждого эндпоинта.
export const {
useGetPostsQuery,
useGetPostByIdQuery,
useAddPostMutation,
useUpdatePostMutation,
useDeletePostMutation,
} = postApi;
// store.ts - Настройка Redux стора
import { configureStore } from '@reduxjs/toolkit';
import { postApi } from './api';
export const store = configureStore({
reducer: {
// Добавляем редьюсер, сгенерированный createApi, по его reducerPath
[postApi.reducerPath]: postApi.reducer,
// ... другие редьюсеры
},
// Добавляем мидлвар API, чтобы включить кеширование, инвалидацию, опрос и т.д.
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(postApi.middleware),
});
// Определяем типы для RootState и AppDispatch для строго типизированных useSelector/useDispatch
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

Теперь, когда API определен и стор настроен, мы можем использовать сгенерированные хуки в наших компонентах:

// PostList.tsx - Пример использования в React компоненте
import React from 'react';
import { useGetPostsQuery, useAddPostMutation, useDeletePostMutation } from './api';
import { Post, NewPost } from './types'; // Импортируем типы
function PostList() {
// Использование хука для запроса данных.
// data: массив постов, isLoading: булево состояние загрузки, error: объект ошибки.
const { data: posts, isLoading, error } = useGetPostsQuery();
// Использование хука для мутации. addPost - это функция для выполнения мутации.
// isLoading: булево состояние загрузки мутации, isSuccess: успех мутации.
const [addPost, { isLoading: isAddingPost }] = useAddPostMutation();
const [deletePost, { isLoading: isDeletingPost }] = useDeletePostMutation();
const handleAddPost = async () => {
const newPost: NewPost = {
title: `Новый пост ${Date.now()}`,
body: 'Содержимое нового поста.',
userId: 1,
};
try {
await addPost(newPost).unwrap(); // .unwrap() позволяет поймать ошибки мутации
console.log('Пост успешно добавлен!');
} catch (err) {
console.error('Ошибка при добавлении поста:', err);
}
};
const handleDeletePost = async (id: number) => {
try {
await deletePost(id).unwrap();
console.log(`Пост с ID ${id} успешно удален!`);
} catch (err) {
console.error(`Ошибка при удалении поста ${id}:`, err);
}
};
if (isLoading) return <div>Загружаем посты...</div>;
// Обработка ошибки, если 'status' есть в объекте ошибки
if (error) return <div>Ошибка при загрузке постов: {'status' in error ? error.status : 'Неизвестная ошибка'}</div>;
return (
<div>
<h2>Список Постов</h2>
<button onClick={handleAddPost} disabled={isAddingPost}>
{isAddingPost ? 'Добавляем...' : 'Добавить Новый Пост'}
</button>
<ul>
{posts?.map((post: Post) => ( // TypeScript помогает нам, указывая, что post имеет тип Post
<li key={post.id}>
<strong>{post.title}</strong>
<p>{post.body}</p>
<button onClick={() => handleDeletePost(post.id)} disabled={isDeletingPost}>
{isDeletingPost ? 'Удаляем...' : 'Удалить'}
</button>
</li>
))}
</ul>
</div>
);
}
export default PostList;

RTK Query предлагает множество продвинутых возможностей, которые становятся еще мощнее с TypeScript.

Иногда тебе нужно выполнить запрос только при определенных условиях. RTK Query позволяет это сделать с помощью опции skip. Когда skip равен true, запрос не будет выполнен, а хук вернет состояние, как будто данных нет, а загрузка завершена. skipToken используется, когда аргумент запроса может быть undefined и ты хочешь пропустить запрос в этом случае.

// PostDetail.tsx - Пример с условным выполнением
import React from 'react';
import { useGetPostByIdQuery } from './api';
import { skipToken } from '@reduxjs/toolkit/query/react';
interface PostDetailProps {
postId: number | undefined; // ID может быть undefined
}
function PostDetail({ postId }: PostDetailProps) {
// Запрос будет выполнен только если postId не undefined.
// skipToken - это специальный символ, который указывает RTK Query пропустить запрос.
const { data: post, isLoading, error } = useGetPostByIdQuery(
postId !== undefined ? postId : skipToken
);
if (postId === undefined) {
return <div>Выберите пост для просмотра деталей.</div>;
}
if (isLoading) return <div>Загружаем детали поста...</div>;
if (error) return <div>Ошибка при загрузке поста: {'status' in error ? error.status : 'Неизвестная ошибка'}</div>;
return (
<div>
<h3>Детали Поста (ID: {postId})</h3>
{post ? (
<>
<h4>{post.title}</h4>
<p>{post.body}</p>
</>
) : (
<div>Пост не найден.</div>
)}
</div>
);
}
export default PostDetail;

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

// api.ts (Добавляем эндпоинт с пагинацией и трансформацией)
// ...внутри postApi, в секции endpoints
// Добавим новый эндпоинт для получения постов с пагинацией
// <ТипТрансформированногоОтвета, ТипАргументов>
getPaginatedPosts: builder.query<PaginatedResponse<Post>, { page: number; limit: number }>({
query: ({ page, limit }) => `posts?_page=${page}&_limit=${limit}`,
// Пример трансформации ответа.
// Этот API не возвращает метаданные пагинации, поэтому мы их имитируем.
// В реальном приложении бэкенд бы возвращал totalItems и т.д.
transformResponse: (rawPosts: Post[], meta, arg) => {
// В реальном приложении meta.response.headers.get('X-Total-Count')
// мог бы дать общее количество элементов
const totalItems = 100; // Допустим, всего 100 постов
const currentPage = arg.page;
const itemsPerPage = arg.limit;
const totalPages = Math.ceil(totalItems / itemsPerPage);
return {
data: rawPosts,
meta: {
totalItems,
currentPage,
itemsPerPage,
totalPages,
},
};
},
providesTags: (result) =>
result
? [...result.data.map(({ id }) => ({ type: 'Posts' as const, id })), { type: 'Posts', id: 'LIST' }]
: [{ type: 'Posts', id: 'LIST' }],
}),
PaginatedPostList.tsx
import React, { useState } from 'react';
import { useGetPaginatedPostsQuery } from './api';
function PaginatedPostList() {
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(10);
const { data: paginatedData, isLoading, error } = useGetPaginatedPostsQuery({ page, limit });
if (isLoading) return <div>Загружаем страницы постов...</div>;
if (error) return <div>Ошибка: {'status' in error ? error.status : 'Неизвестная ошибка'}</div>;
const { data: posts, meta } = paginatedData || { data: [], meta: null };
return (
<div>
<h2>Посты с Пагинацией</h2>
<div>
<button onClick={() => setPage((prev) => Math.max(1, prev - 1))} disabled={page === 1}>
Предыдущая
</button>
<span> Страница {page} из {meta?.totalPages || '...'} </span>
<button onClick={() => setPage((prev) => (meta && prev < meta.totalPages ? prev + 1 : prev))} disabled={page === meta?.totalPages}>
Следующая
</button>
</div>
<ul>
{posts.map((post) => (
<li key={post.id}>
<strong>{post.title}</strong> (ID: {post.id})
</li>
))}
</ul>
</div>
);
}
export default PaginatedPostList;
  1. Недостаточная Типизация: Самая частая ошибка. Использование any или частичная типизация.
    • Решение: Всегда создавай интерфейсы или типы для аргументов запросов, ответов, и данных в теле мутаций. TypeScript будет твоим лучшим другом, указывая на несоответствия.
  2. Проблемы с Инвалидацией Кеша: После мутации данные в компонентах не обновляются.
    • Причина: Забыли или неправильно настроили providesTags и invalidatesTags.
    • Решение: Убедись, что твои query эндпоинты имеют providesTags, которые соответствуют invalidatesTags твоих mutation эндпоинтов. Используй уникальные идентификаторы (например, { type: 'Posts', id: 'LIST' } для списка, и { type: 'Posts', id: postId } для конкретного элемента).
  3. Кеширование по Аргументам: Ты думаешь, что RTK Query всегда выдает свежие данные, но он отдает закешированные.
    • Причина: RTK Query кеширует данные на основе аргументов запроса. Если аргументы не меняются, он отдаст закешированные данные.
    • Решение: Если тебе нужны абсолютно свежие данные, несмотря на кеш, можно использовать refetch() из хука запроса, или инвалидировать нужный тег принудительно (хотя это редко требуется). Чаще всего, проблема в неправильной инвалидации после мутаций.
  4. Сложность с Ошибками Мутаций: Хук мутации возвращает error, но ты не знаешь, как его обработать.
    • Решение: Используй .unwrap() на результате мутации (например, await addPost(data).unwrap();). Это заставит мутацию выбросить ошибку, которую ты можешь поймать с помощью try...catch. Тип ошибки будет соответствовать FetchBaseQueryError | SerializedError.

Время закрепить знания! Вот несколько задач для самостоятельной работы.

  1. Создание Пользовательского API:
    • Определи интерфейсы User (id, name, email) и NewUser (name, email), UpdatedUser.
    • Создай новый userApi с помощью createApi.
    • Добавь эндпоинты:
      • getUsers: получает список всех пользователей.
      • getUserById: получает пользователя по ID (с providesTags для конкретного пользователя).
      • addUser: добавляет нового пользователя (инвалидирует список getUsers).
      • updateUser: обновляет существующего пользователя (инвалидирует и список, и конкретного пользователя).
  2. Запрос с Зависимостью:
    • Создай компонент UserPosts, который принимает userId в качестве пропса.
    • Внутри этого компонента, используй useGetPostsQuery для получения постов, но только если userId передан (используй skipToken).
  3. Фильтрация и Сортировка (с трансформацией):
    • Расширь эндпоинт getUsers, чтобы он принимал аргументы для фильтрации (например, nameContains?: string) и сортировки (sortBy?: 'name' | 'email').
    • Используй transformResponse для применения этой логики на клиенте (если бэкенд не поддерживает).
    • Подсказка: transformResponse получает rawResult и meta, arg. arg — это аргументы, которые были переданы в запрос.

Всегда стремись к максимальной типизации. Чем больше TypeScript знает о твоих данных, тем меньше вероятность ошибок во время выполнения и тем легче поддерживать код. Используй providesTags и invalidatesTags с умом, чтобы кеш RTK Query всегда был актуальным. Это один из самых мощных механизмов RTK Query, который сильно упрощает жизнь. Не бойся экспериментировать с transformResponse — это отличный способ адаптировать данные бэкенда к нуждам фронтенда, не затрагивая сами API.

RTK Query в связке с TypeScript — это мощнейший дуэт, который позволяет создавать сложные, производительные и надежные приложения, забыв о большинстве рутинных задач по работе с данными. Удачи в освоении!