RTK Query — це не просто "fetcher" даних. Це повноцінний Server State Manager, який інтегрований у Redux Toolkit. Він перевертає уявлення про те, як ми працюємо з даними на клієнті, зміщуючи фокус з "ручного управління loading/error/data" на "декларативне описування залежностей".
Цей гайд — глибоке занурення в RTK Query. Ми не будемо зупинятися на банальному "як зробити GET запит". Ми розберемо життєвий цикл кешу, стратегії інвалідації при мутаціях, оптимістичні оновлення зі складними rollback-сценаріями, інтеграцію з WebSockets, Server-Side Rendering (SSR) та складну автентифікацію.
У традиційному Redux (до 2020 року) ми змішували все в одну купу.
Client State: Теми (світла/темна), форми (isModalOpen), фільтри. Це дані, якими володіє клієнт. Вони синхронні і надійні.
Server State: Список юзерів, деталі товару. Це дані, якими володіє сервер. Клієнт має лише кеш (snapshot) цих даних на певний момент часу. Цей кеш:
Коли ми намагаємося керувати Server State методами Client State (ручні редюсери, isFetching прапорці), ми отримуємо "спагетті-код", баги з race conditions та застарілі дані.
RTK Query вирішує ці проблеми, беручи на себе управління Server State.
createApi – Серце СистемиcreateApi — це фабрика, яка генерує "слайс" для API. Але на відміну від createSlice, вона генерує не тільки редюсер, а й middleware та хуки.
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export const api = createApi({
reducerPath: 'api', // Унікальний ключ в Redux state
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
endpoints: (builder) => ({
// ... endpoints
}),
})
Коли ви викликаєте createApi:
api/executeQuery/pending, керує мережевими запитами та Promises.fetchBaseQuery – Це не просто fetchЦе легка обгортка над fetch, яка:
fetch).Ви можете написати свій власний baseQuery (наприклад, використовуючи Axios або GraphQL client).
/* Custom Base Query з Axios */
const axiosBaseQuery =
({ baseUrl } = { baseUrl: '' }) =>
async ({ url, method, data, params }) => {
try {
const result = await axios({ url: baseUrl + url, method, data, params })
return { data: result.data }
} catch (axiosError) {
let err = axiosError
return {
error: {
status: err.response?.status,
data: err.response?.data || err.message,
},
}
}
}
Query — це операція читання (READ).
getPost: builder.query({
query: (id) => `post/${id}`,
// Опції...
})
Це найважливіша частина. Як RTK Query розуміє, коли робити запит, а коли брати з кешу?
useGetPostQuery(1).1 серіалізується. Кеш-ключ стає getPost(1).getPost(1)?
pending, fetch data, save to store, dispatch fulfilled.getPost(1). Лічильник підписників (reference count) стає 1.keepUnusedDataFor (за замовчуванням 60 сек).getPost(1), дані видаляються з пам'яті.skip)Іноді нам не потрібно робити запит одразу.
const { data } = useGetPostQuery(id, { skip: !id })
Якщо skip: true:
uninitialized.isUninitialized: true.Для даних, які змінюються часто (наприклад, курси валют), можна увімкнути поллінг.
const { data } = useGetRatesQuery(undefined, {
pollingInterval: 3000, // Кожні 3 секунди
})
RTK Query розумний:
skipPollingIfUnfocused: false.transformResponseЧасто сервер повертає дані, які нам не подобаються. Або ми хочемо валідувати схему (наприклад, з Zod).
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
name: z.string(),
});
getUsers: builder.query({
query: () => 'users',
transformResponse: (response) => {
// Валідація! Якщо не пройде, випаде помилка
const parsed = z.array(UserSchema).parse(response);
// Трансформація: додаємо computed fields
return parsed.map(user => ({
...user,
displayName: user.name.toUpperCase()
}));
},
}),
Mutation — це операція зміни (CREATE, UPDATE, DELETE).
updatePost: builder.mutation({
query: ({ id, ...patch }) => ({
url: `post/${id}`,
method: 'PATCH',
body: patch,
}),
})
[trigger, result]. Ви самі викликаєте trigger(), коли користувач натискає кнопку.trigger створює новий запис (але можна налаштувати fixedCacheKey для шарінгу статусу між компонентами).const [updatePost, { isLoading }] = useUpdatePostMutation()
const handleSave = async () => {
try {
await updatePost({ id: 1, title: 'New' }).unwrap()
// unwrap() дозволяє ловити помилки через try/catch
} catch (err) {
console.error(err)
}
}
Це магія, яка пов'язує Queries і Mutations. Як сказати списку постів оновитися, коли ми додали новий пост?
У "старому" світі ми б робили це вручну. У RTK Query ми використовуємо Tags.
tagTypes: ['Post', 'User'] в createApi.getPosts: builder.query({
query: () => 'posts',
providesTags: ['Post'],
}),
addPost: builder.mutation({
query: (body) => ({ url: 'posts', method: 'POST', body }),
invalidatesTags: ['Post'],
})
getPosts завантажив дані і позначив їх тегом Post.addPost виконався успішно. Middleware бачить invalidatesTags: ['Post'].providesTags: ['Post'].getPosts.getPosts.Що робити, якщо ми оновили один пост? Навіщо перечитувати весь список? Або навпаки, якщо ми оновили пост #5, чи треба оновлювати список?
Потрібна гранулярність. Теги можуть бути об'єктами: { type: 'Post', id: 5 }.
Сценарій 1: Список та Деталі
getPosts: builder.query({
query: () => 'posts',
// Повертає масив тегів:
// [ { type: 'Post', id: 1 }, { type: 'Post', id: 2 }, ..., { type: 'Post', id: 'LIST' } ]
providesTags: (result) =>
result
? [
...result.map(({ id }) => ({ type: 'Post', id })),
{ type: 'Post', id: 'LIST' },
]
: [{ type: 'Post', id: 'LIST' }],
}),
getPost: builder.query({
query: (id) => `posts/${id}`,
providesTags: (result, error, id) => [{ type: 'Post', id }],
}),
Сценарій 2: Мутації
// Оновлення одного поста
updatePost: builder.mutation({
query: ({ id, ...patch }) => ...,
// Інвалідує ТІЛЬКИ цей пост. Список НЕ буде перечитано (якщо він не 'provides' цей конкретний ID)
// АЛЕ, оскільки наш список (вище) 'provides' всі ID, то і список оновиться.
// Це правильна поведінка, бо title міг змінитися і в списку.
invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }],
}),
// Додавання нового поста
addPost: builder.mutation({
query: (body) => ...,
// Інвалідує "LIST". Це змусить getPosts перечитатися повністю.
invalidatesTags: [{ type: 'Post', id: 'LIST' }],
}),
Інвалідація тегів — це круто, але це "Pessimistic Update".
Optimistic Update:
В RTK Query це реалізується через onQueryStarted.
likePost: builder.mutation({
query: (id) => ({
url: `posts/${id}/like`,
method: 'POST',
}),
// lifecycle hook mutations
async onQueryStarted(id, { dispatch, queryFulfilled }) {
// 1. Оптимістично оновлюємо кеш `getPost(id)`
const patchResult = dispatch(
api.util.updateQueryData('getPost', id, (draft) => {
// draft — це Immer draft. Можемо мутувати!
draft.likes += 1;
draft.likedByUser = true;
})
)
// 2. Також оновлюємо `getPosts` (список), якщо треба
const listPatchResult = dispatch(
api.util.updateQueryData('getPosts', undefined, (draft) => {
const post = draft.find(p => p.id === id);
if (post) {
post.likes += 1;
post.likedByUser = true;
}
})
);
try {
// 3. Чекаємо реальної відповіді сервера
await queryFulfilled
} catch {
// 4. ПОМИЛКА! Відкатуємо зміни
patchResult.undo();
listPatchResult.undo();
// Можна показати тост
toast.error("Не вдалося лайкнути. перевірте інтернет.");
}
},
}),
Це UX рівня Facebook/Twitter. Користувач не відчуває затримки мережі.
Що як сервер присилає дані сам? (WebSockets, Server-Sent Events). RTK Query може інтегрувати пуш-повідомлення прямо в кеш.
Використовуємо onCacheEntryAdded.
getMessages: builder.query({
query: (channelId) => `channels/${channelId}/messages`,
async onCacheEntryAdded(
channelId,
{ updateCachedData, cacheDataLoaded, cacheEntryRemoved }
) {
// 1. Створюємо сокет
const ws = new WebSocket('ws://localhost:8080');
try {
// 2. Чекаємо поки початкові дані завантажаться
await cacheDataLoaded;
// 3. Слухаємо події
const listener = (event) => {
const data = JSON.parse(event.data);
if (data.channelId !== channelId) return;
// 4. Оновлюємо кеш "на льоту"
updateCachedData((draft) => {
draft.push(data.message);
});
};
ws.addEventListener('message', listener);
} catch {
// no-op handle errors
}
// 5. Cleanup при unmount (коли кеш видаляється)
await cacheEntryRemoved;
ws.close();
},
}),
Це дозволяє вам мати Real-time оновлення без написання окремих редюсерів для сокетів! Весь стейт чату живе в одному endpoint.
Для великих додатків не варто пхати всі 100+ endpoints в один файл api.js. Це роздуває бандл.
RTK Query дозволяє розбивати API на частини (code splitting) і завантажувати їх ліниво.
/* src/api/baseApi.js */
// Порожній API об'єкт
export const baseApi = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/' }),
endpoints: () => ({}), // Порожньо!
});
/* src/features/users/usersApi.js */
import { baseApi } from '../../api/baseApi';
// Розширюємо baseApi
export const usersApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getUsers: builder.query({ ... }),
updateUser: builder.mutation({ ... }),
}),
overrideExisting: false,
});
export const { useGetUsersQuery } = usersApi;
Ви можете імпортувати usersApi тільки там, де воно треба. Це ідеально для Lazy Loading маршрутів.
Це найскладніша частина для новачків. Як автоматично оновити токен, коли він протух?
В axios ми використовуємо interceptors. В RTK Query ми пишемо wrapper around baseQuery.
import { fetchBaseQuery } from '@reduxjs/toolkit/query'
import { tokenReceived, loggedOut } from './authSlice'
const baseQuery = fetchBaseQuery({
baseUrl: '/api',
prepareHeaders: (headers, { getState }) => {
const token = getState().auth.token
if (token) {
headers.set('authorization', `Bearer ${token}`)
}
return headers
},
})
const baseQueryWithReauth = async (args, api, extraOptions) => {
// 1. Спробуємо виконати запит
let result = await baseQuery(args, api, extraOptions)
// 2. Якщо 401 Unauthorized
if (result.error && result.error.status === 401) {
// 3. Спробуємо оновити токен
const refreshResult = await baseQuery('/refresh', api, extraOptions)
if (refreshResult.data) {
// 4. Збережемо новий токен
api.dispatch(tokenReceived(refreshResult.data))
// 5. Повторимо початковий запит
result = await baseQuery(args, api, extraOptions)
} else {
// 6. Якщо refresh не вдався - logout
api.dispatch(loggedOut())
}
}
return result
}
export const api = createApi({
baseQuery: baseQueryWithReauth, // Використовуємо наш wrapper
endpoints: () => ({}),
})
Цей код працює глобально для всіх endpoint-ів!
Як глобально обробляти помилки (наприклад, 500 error)? Для цього використовуємо middleware.
import { isRejectedWithValue } from '@reduxjs/toolkit';
export const rtkQueryErrorLogger = (api) => (next) => (action) => {
// RTK Query dispatch-ить спец. екшени при помилках
if (isRejectedWithValue(action)) {
console.warn('Ми зловили помилку RTK Query!');
if (action.payload.status === 500) {
toast.error('Сервер впав. Спробуйте пізніше.');
}
}
return next(action);
};
// В store.js
middleware: (gDM) => gDM().concat(api.middleware, rtkQueryErrorLogger),
Типізація результатів — це просто. А як щодо типізації помилок?
const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/' }),
endpoints: (builder) => ({
getData: builder.query<
ResultType, // Успішна відповідь
ArgType // Аргумент (id etc)
>({
query: (arg) => `data/${arg}`,
}),
}),
})
Якщо ваш сервер повертає специфічні помилки { message: string, code: number }, ви можете типізувати їх на рівні baseQuery.
const baseQuery = fetchBaseQuery({ baseUrl: '/' })
const customBaseQuery: BaseQueryFn<
string | FetchArgs, // args
unknown, // result
{ message: string; code: number } // FetchBaseQueryError | CustomError
> = async (args, api, extraOptions) => {
let result = await baseQuery(args, api, extraOptions)
return result
}
RTK Query — це просто функції та хуки. Ми можемо тестувати їх по-різному.
Ми можемо тестувати редюсери, але це нудно.
Це "золотий стандарт". Ми перехоплюємо мережеві запити.
/* setupTests.js */
import { setupServer } from 'msw/node'
import { rest } from 'msw'
export const handlers = [
rest.get('/api/user', (req, res, ctx) => {
return res(ctx.json({ name: 'John Doe' }))
}),
]
export const server = setupServer(...handlers)
/* User.test.js */
import { renderHook, waitFor } from '@testing-library/react'
import { Provider } from 'react-redux'
import { useGetUserQuery } from './api'
import { store } from './store' // реальний стор з API
function Wrapper({ children }) {
return <Provider store={store}>{children}</Provider>
}
test('returns user data', async () => {
const { result } = renderHook(() => useGetUserQuery(), { wrapper: Wrapper })
// Спочатку loading
expect(result.current.isLoading).toBe(true)
// Чекаємо успіху
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toEqual({ name: 'John Doe' })
})
Ми не мокаємо RTK Query! Ми мокаємо мережу. Це дає нам 100% впевненість, що наш код працює.
Якщо ви використовуєте Next.js (pages router) або SSR, вам потрібно передати стан з сервера на клієнт.
RTK Query має спеціальний action hydrate.
import { createWrapper } from 'next-redux-wrapper'
import { api } from './api'
// Спеціальний reducer для гідрації
const rootReducer = (state, action) => {
if (action.type === HYDRATE) {
return { ...state, ...action.payload }
}
return combinedReducer(state, action)
}
export const wrapper = createWrapper(makeStore)
// В getServerSideProps
export const getServerSideProps = wrapper.getServerSideProps((store) => async (context) => {
// 1. Dispatch запит
store.dispatch(api.endpoints.getPosts.initiate())
// 2. Чекаємо завершення ВСІХ запитів
await Promise.all(store.dispatch(api.util.getRunningQueriesThunk()))
return { props: {} }
})
extractRehydrationInfoУ нових версіях RTK Query (v1.7+) це ще простіше.
export const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/' }),
extractRehydrationInfo(action, { reducerPath }) {
if (action.type === HYDRATE) {
return action.payload[reducerPath]
}
},
endpoints: () => ({}),
})
Реалізація нескінченної прокрутки з RTK Query.
Є два підходи:
Для Infinite Scroll підходить Merge.
/* api.js */
getInfinitePosts: builder.query({
query: (page) => `posts?page=${page}`,
// Ця функція дозволяє мержити нові дані з існуючим кешем!
serializeQueryArgs: ({ endpointName }) => {
return endpointName; // Ігноруємо аргумент 'page' в ключі кешу
},
merge: (currentCache, newItems) => {
currentCache.push(...newItems);
},
// Змушуємо refetch, коли змінюється сторінка (навіть якщо ключ той самий)
forceRefetch({ currentArg, previousArg }) {
return currentArg !== previousArg;
},
}),
В компоненті:
function Feed() {
const [page, setPage] = useState(1)
const { data: posts, isFetching } = useGetInfinitePostsQuery(page)
return (
<div>
{posts.map((post) => (
<Post key={post.id} data={post} />
))}
<button onClick={() => setPage(page + 1)} disabled={isFetching}>
Load More
</button>
</div>
)
}
Тут ми використовуємо serializeQueryArgs, щоб сказати RTK Query: "Всі сторінки належать до одного запису в кеші". А merge — "Додавай нові дані в кінець масиву".
У вас є старий код на createAsyncThunk. Як мігрувати?
api слайс.Приклад міграції:
Before (Thunk):
// thunks.js
export const fetchUser = createAsyncThunk('user/fetch', async (id) => {
const res = await fetch(`/users/${id}`)
return res.json()
})
// slice.js
extraReducers: (builder) => {
builder.addCase(fetchUser.fulfilled, (state, action) => {
state.user = action.payload
})
}
After (RTK Query):
// api.js
getUser: builder.query({
query: (id) => `users/${id}`,
})
// component.js
const { data: user } = useGetUserQuery(id)
Ми видалили:
В: Так! RTK Query — це фреймворк-агностик бібліотека. Хуки (useQuery) — це лише обгортка. Ви можете використовувати api.endpoints.getUsers.initiate() в будь-якому JS коді (наприклад, Vue, Svelte, Angular або Node.js).
В: Chain Queries. Використовуйте skip.
const { data: user } = useGetUserQuery(id)
const { data: posts } = useGetPostsQuery(user?.id, {
skip: !user, // Чекаємо поки user завантажиться
})
В: Так. Login — це мутація (login mutation). Вона повертає токен, який ви зберігаєте в authSlice.
В: api.util.resetApiState() — очищає весь кеш. Корисно при логауті.
RTK Query надзвичайно ефективний завдяки підходу до підписок. Він не робить diffing глибоких об'єктів стейту, як це робить useSelector.
Він перевіряє лише посилання.
| Feature | RTK Query | React Query | SWO | Apollo |
|---|---|---|---|---|
| Backend | Any | Any | Any | GraphQL |
| Size (gzip) | ~2kb (on top of RTK) | ~13kb | ~4kb | ~30kb |
| Dependant Queries | ✅ | ✅ | ✅ | ✅ |
| Normalized Cache | ❌ (Document Cache) | ❌ | ❌ | ✅ |
| Auto-Generated Hooks | ✅ | ❌ | ❌ | ✅ (CodeGen) |
Чому Document Cache це добре? Normalized Cache (як в Apollo) дуже складний для підтримки. Document Cache (кешування відповідей) — простий і передбачуваний. RTK Query використовує теги для симуляції Normalized поведінки (інвалідації), що дає найкраще з обох світів.
Redux DevTools — це ваші очі. Якщо щось не працює, дивіться сюди.
RTK Query dispatch-ить специфічні actions, за якими треба стежити:
api/executeQuery/pending: Початок запиту.api/executeQuery/fulfilled: Успіх. Payload містить дані.api/executeQuery/rejected: Помилка.api/subscriptions/internal_subscribe: Компонент змонтувався і підписався.api/subscriptions/internal_unsubscribe: Компонент розмонтувався.internal_subscribe. Якщо ні — ви не викликали хук або передали skip: true.args. Якщо вони undefined (а не мали бути), RTK Query може пропустити запит.pending -> fulfilled за секунду.useGetPostsQuery({ page: 1 }). { page: 1 } !== { page: 1 }.useMemo або стабільні примітиви.Як НЕ треба робити.
useEffect для синхронізації❌ Погано:
const { data } = useGetPostsQuery()
const [localPosts, setLocalPosts] = useState([])
useEffect(() => {
if (data) setLocalPosts(data)
}, [data])
✅ Добре:
Використовуйте data напряму! Якщо треба трансформувати — transformResponse або selectFromResult.
isLoading❌ Погано:
const { data } = useGetPostsQuery();
return <div>{data.map(...)}</div>; // Crash! data is undefined initially
✅ Добре:
Завжди перевіряйте isLoading або використовуйте data?.map.
Не намагайтеся вручну керувати кешем (updateQueryData), якщо вам це не критично. Інвалідація тегів — простіша і надійніша. Робіть Optimistic Updates тільки там, де UX цього вимагає (лайки, повідомлення).
transformErrorResponseЩо як бекенд повертає 200 OK, але в тілі { status: 'error', message: 'Validation failed' }?
RTK Query подумає, що все добре. Нам треба це виправити.
fetchBaseQuery({
baseUrl: '/api',
// Перехоплюємо відповідь
responseHandler: async (response) => {
const text = await response.text()
const json = text.length ? JSON.parse(text) : {}
if (json.status === 'error') {
// Викидаємо помилку, щоб RTK Query перевів запит в rejected
throw { status: 500, data: json }
}
return json
},
})
Або, якщо вам треба просто нормалізувати помилки:
endpoints: (builder) => ({
getUsers: builder.query({
query: () => 'users',
// Трансформуємо помилку в зручний формат
transformErrorResponse: (response, meta, arg) => {
return {
message: response.data?.message || 'Unknown Error',
code: response.status
};
},
}),
}),
Часте питання: "Де писати бізнес-логіку? В компоненті, в thunk чи в transformResponse?"
transformResponseІдеально для форматування даних.
onQueryStartedДля Side Effects, які стосуються API.
Для View Logic.
Якщо логіка використовується в декількох місцях.
export const useActiveUsers = () => {
const { data } = useGetUsersQuery()
// Селектор-логіка
return useMemo(() => data?.filter((u) => u.isActive) ?? [], [data])
}
Найшвидший запит — це той, який вже зроблений.
usePrefetch дозволяє завантажити дані до того, як користувач перейде на сторінку.
import { usePrefetch } from './api'
function UserList({ users }) {
const prefetchUser = usePrefetch('getUser')
return (
<div>
{users.map((user) => (
<Link key={user.id} to={`/users/${user.id}`} onMouseEnter={() => prefetchUser(user.id)}>
{user.name}
</Link>
))}
</div>
)
}
Якщо у вас є вкладені запити, ви можете почати завантаження другого рівня ще на сервері або на початку першого.
/* Prefecthing in Route component */
function UserPage({ id }) {
useGetUserQuery(id); // Завантажуємо User
usePrefetch('getUserPosts')(id); // Паралельно починаємо вантажити пости!
return ...
}
Використовуйте prefetch обережно, щоб не перевантажити мережу.
RTK Query — це інструмент "швейцарський ніж" для роботи з даними.
Перехід на RTK Query часто зменшує розмір кодової бази Redux-додатку на 50-70%.
Quiz: RTK Query
Наступний крок: Архітектура
Мемоізація та Селектори: Повний Гайд по Reselect
У світі Redux селектори часто недооцінюють. Багато розробників сприймають їх просто як функції для отримання шматочка даних зі стейту. Але насправді, селектори — це потужний шар абстракції, який відповідає за ефективність, інкамисуляцію та обчислення похідних даних.
Архітектура та Best Practices
Тепер, коли ви знаєте всі інструменти Redux Toolkit, давайте поговоримо про те, як організувати код, щоб проєкт залишався підтримуваним навіть при зростанні.