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 и зачем он нужен?
Заголовок раздела «Что такое RTK Query и зачем он нужен?»RTK Query — это инструмент для работы с данными, построенный на базе Redux Toolkit. Он предоставляет высокоуровневые abstractions для запросов данных и управления состоянием, значительно уменьшая объем бойлерплейта и повышая производительность.
Основные преимущества:
- Автоматическое кеширование: Запросы кешируются и повторно используются.
- Автоматическая инвалидация: После мутаций кеш может быть инвалидирован, чтобы данные обновились.
- Генерация хуков: Для каждого эндпоинта автоматически создаются React хуки.
- Типизация “из коробки”: С TypeScript ты точно знаешь типы данных в запросах, ответах и состоянии.
- Минимизация бойлерплейта: Значительно меньше кода по сравнению с ручной реализацией.
Давай посмотрим, как это работает!
🧱 Основные Строительные Блоки RTK Query
Заголовок раздела «🧱 Основные Строительные Блоки RTK Query»Сердцем 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 APIimport { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';import { Post, NewPost, UpdatedPost, PaginatedResponse } from './types'; // Импортируем наши типы
// Создаем наш APIexport 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/useDispatchexport 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.
1. Условное Выполнение Запроса с skip / skipToken
Заголовок раздела «1. Условное Выполнение Запроса с skip / skipToken»Иногда тебе нужно выполнить запрос только при определенных условиях. 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;2. Трансформация Ответа с transformResponse
Заголовок раздела «2. Трансформация Ответа с transformResponse»Часто данные с бэкенда приходят в формате, который не совсем удобен для использования на фронтенде. 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' }],}),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;🐛 Типичные Ошибки и Как Их Избежать
Заголовок раздела «🐛 Типичные Ошибки и Как Их Избежать»- Недостаточная Типизация: Самая частая ошибка. Использование
anyили частичная типизация.- Решение: Всегда создавай интерфейсы или типы для аргументов запросов, ответов, и данных в теле мутаций. TypeScript будет твоим лучшим другом, указывая на несоответствия.
- Проблемы с Инвалидацией Кеша: После мутации данные в компонентах не обновляются.
- Причина: Забыли или неправильно настроили
providesTagsиinvalidatesTags. - Решение: Убедись, что твои
queryэндпоинты имеютprovidesTags, которые соответствуютinvalidatesTagsтвоихmutationэндпоинтов. Используй уникальные идентификаторы (например,{ type: 'Posts', id: 'LIST' }для списка, и{ type: 'Posts', id: postId }для конкретного элемента).
- Причина: Забыли или неправильно настроили
- Кеширование по Аргументам: Ты думаешь, что RTK Query всегда выдает свежие данные, но он отдает закешированные.
- Причина: RTK Query кеширует данные на основе аргументов запроса. Если аргументы не меняются, он отдаст закешированные данные.
- Решение: Если тебе нужны абсолютно свежие данные, несмотря на кеш, можно использовать
refetch()из хука запроса, или инвалидировать нужный тег принудительно (хотя это редко требуется). Чаще всего, проблема в неправильной инвалидации после мутаций.
- Сложность с Ошибками Мутаций: Хук мутации возвращает
error, но ты не знаешь, как его обработать.- Решение: Используй
.unwrap()на результате мутации (например,await addPost(data).unwrap();). Это заставит мутацию выбросить ошибку, которую ты можешь поймать с помощьюtry...catch. Тип ошибки будет соответствоватьFetchBaseQueryError | SerializedError.
- Решение: Используй
🎯 Практика
Заголовок раздела «🎯 Практика»Время закрепить знания! Вот несколько задач для самостоятельной работы.
- Создание Пользовательского API:
- Определи интерфейсы
User(id, name, email) иNewUser(name, email),UpdatedUser. - Создай новый
userApiс помощьюcreateApi. - Добавь эндпоинты:
getUsers: получает список всех пользователей.getUserById: получает пользователя по ID (сprovidesTagsдля конкретного пользователя).addUser: добавляет нового пользователя (инвалидирует списокgetUsers).updateUser: обновляет существующего пользователя (инвалидирует и список, и конкретного пользователя).
- Определи интерфейсы
- Запрос с Зависимостью:
- Создай компонент
UserPosts, который принимаетuserIdв качестве пропса. - Внутри этого компонента, используй
useGetPostsQueryдля получения постов, но только еслиuserIdпередан (используйskipToken).
- Создай компонент
- Фильтрация и Сортировка (с трансформацией):
- Расширь эндпоинт
getUsers, чтобы он принимал аргументы для фильтрации (например,nameContains?: string) и сортировки (sortBy?: 'name' | 'email'). - Используй
transformResponseдля применения этой логики на клиенте (если бэкенд не поддерживает). - Подсказка:
transformResponseполучаетrawResultиmeta,arg.arg— это аргументы, которые были переданы в запрос.
- Расширь эндпоинт
💡 Совет
Заголовок раздела «💡 Совет»Всегда стремись к максимальной типизации. Чем больше TypeScript знает о твоих данных, тем меньше вероятность ошибок во время выполнения и тем легче поддерживать код. Используй providesTags и invalidatesTags с умом, чтобы кеш RTK Query всегда был актуальным. Это один из самых мощных механизмов RTK Query, который сильно упрощает жизнь. Не бойся экспериментировать с transformResponse — это отличный способ адаптировать данные бэкенда к нуждам фронтенда, не затрагивая сами API.
RTK Query в связке с TypeScript — это мощнейший дуэт, который позволяет создавать сложные, производительные и надежные приложения, забыв о большинстве рутинных задач по работе с данными. Удачи в освоении!