Що таке RTK Query в Redux Toolkit?
RTK Query - це бібліотека для отримання даних і кешування, вбудована в Redux Toolkit, яка автоматично генерує хуки, управляє кешем і відстежує стани завантаження з одного виклику createApi.
Теорія
TL;DR
- Уяви розумний кеш-шар: описуєш ендпоінти один раз, RTK Query сам робить fetch, зберігає в store і оновлює компоненти.
- Головна відмінність від звичайного Redux: ніяких ручних actions, reducers чи thunks. RTK Query генерує все це з твоїх endpoint-визначень.
- Використовуй, якщо в додатку більше кількох API-ендпоінтів. Для чистого клієнтського стану без серверних даних бери
createSlice. - Теги (
providesTags/invalidatesTags) - основний механізм актуальності даних після мутацій. - Працює і без React, але тоді немає автогенерованих хуків.
Швидкий приклад
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const pokemonApi = createApi({
reducerPath: 'pokemonApi',
baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2' }),
endpoints: (builder) => ({
getPokemonByName: builder.query({
query: (name) => `pokemon/${name}`, // GET /pokemon/pikachu
}),
}),
});
export const { useGetPokemonByNameQuery } = pokemonApi; // хук готовийОдин виклик createApi генерує Redux-слайс, запис у middleware і типізований React-хук. Жодних додаткових файлів.
Головна відмінність від ручного Redux async
З чистим Redux отримання даних означає asyncThunk, action creators і reducer-кейси для loading/success/error. RTK Query замінює все це. Описуєш які дані потрібні і URL ендпоінту. RTK Query будує кеш-слайс, підписується на нього коли хук монтується, і прибирає підписку коли компонент анмонтується. Менше коду, та сама надійність.
Коли використовувати
- 5+ API-ендпоінтів: кеш RTK Query виграє у порівнянні з ручними thunks.
- React-додаток, що вже використовує Redux: RTK Query вписується без нової залежності.
- Потрібен polling, оптимістичні оновлення або інвалідація за тегами: все вбудовано.
- Тільки клієнтський стан без серверних даних: залишайся на
createSlice. - Не-React додаток: використовуй RTK Query core API напряму, без хуків.
Як це працює всередині
RTK Query створює окремий Redux-слайс, де зберігає відповіді нормалізовано за ключем запиту. Коли компонент викликає useGetPokemonByNameQuery('pikachu'), хук диспатчить дію підписки. Якщо в кеші вже є свіжа відповідь для цього ключа, хук повертає її одразу. Якщо ні, запускається baseQuery (обгортка навколо fetch). Відповідь потрапляє в Redux-store, і useSyncExternalStore тригерить ре-рендер.
Кеш живе, поки є хоча б один підписник. Коли всі компоненти, що використовують запит, анмонтуються, RTK Query запускає відлік (60 секунд за замовчуванням), після якого видаляє закешовані дані. Це модель збирання сміття.
Інвалідація (invalidation) за тегами працює в зворотному напрямку. Мутація позначає певні теги як застарілі. Будь-який запит, що оголосив providesTags з цими тегами, отримує рефетч при наступному рендері. Саме так додавання до кошика може автоматично оновлювати список продуктів без жодного ручного dispatch.
Auth-токени і заголовки
baseQuery: fetchBaseQuery({
baseUrl: '/api',
prepareHeaders: (headers, { getState }) => {
const token = getState().auth.token;
if (token) headers.set('Authorization', `Bearer ${token}`);
return headers;
},
})prepareHeaders запускається перед кожним запитом. Читання з Redux-store тут означає, що токен залишається актуальним навіть після рефрешу.
Типові помилки
1. Не вказувати providesTags у list-запитах.
// Неправильно: мутації не знають, що потрібно рефетчити
getUsers: builder.query({ query: () => 'users' })
// Правильно: тегуємо кожен елемент для точної інвалідації
getUsers: builder.query({
query: () => 'users',
providesTags: (result) =>
result
? [...result.map(({ id }) => ({ type: 'User', id })), { type: 'User', id: 'LIST' }]
: [{ type: 'User', id: 'LIST' }],
})Без тегів кеш залишається застарілим після мутації. Це найпоширеніша помилка в code review з RTK Query, яку я бачу знову і знову.
2. Ручний refetch() замість invalidatesTags.
// Неправильно: оновлює тільки цей один запит
const [refetch] = useLazyGetPostQuery(); refetch();
// Правильно: інвалідує всі запити, що залежать від Post
addPost: builder.mutation({
query: (body) => ({ url: 'posts', method: 'POST', body }),
invalidatesTags: ['Post'],
})Ручний refetch ламає ланцюжок залежностей. Інші запити, що повертають ті самі дані, залишаються застарілими.
3. Побічні ефекти всередині queryFn.
// Неправильно: очищає localStorage у функції, що може виконуватись паралельно
queryFn: async () => { localStorage.clear(); return { data }; }baseQuery може виконуватися паралельно для кількох ендпоінтів. Побічні ефекти тут викликають race conditions і проблеми з SSR. Тримай queryFn чистою функцією.
4. Не використовувати skip, коли аргумент ще не готовий.
// Неправильно: відправляє запит з undefined в URL
const { data } = useGetUserQuery(userId);
// Правильно: чекаємо поки userId буде доступний
const { data } = useGetUserQuery(userId, { skip: !userId });RTK Query використовує аргумент як ключ кешу. undefined як аргумент валідний, але запит відправляється з undefined в URL, що майже ніколи не потрібно.
Де зустрічається в продакшені
- E-commerce: список продуктів і кошик пов'язані тегами (додавання до кошика автоматично оновлює наявність товару).
- Адмін-панелі:
pollingInterval: 30000для live-лічильників замовлень без WebSockets. - React Native: кеш RTK Query зберігається через
redux-persistдля офлайн-режиму. - Монорепо: один
createApiспільний між пакетами черезinjectEndpoints. - Auth:
prepareHeadersвставляє JWT з Redux-store в кожен запит.
Follow-up питання
Q: Як покроково працює інвалідація за тегами?
A: Мутація з invalidatesTags: ['Post'] виконується після резолву запиту. RTK Query знаходить у кеші всі запити, що оголосили providesTags: ['Post'], позначає їх застарілими і рефетчить при наступному render-циклі.
Q: У чому різниця між query і queryFn?
A: query повертає URL-рядок або конфіг-об'єкт, який baseQuery загортає у fetch-виклик. queryFn дає повний контроль над запитом - корисно для GraphQL, WebSockets або будь-якого не-HTTP джерела даних.
Q: Як відновлювати auth-токен всередині RTK Query?
A: Загортаємо fetchBaseQuery в кастомний baseQueryWithReauth. Перевіряємо на 401, робимо спробу рефрешу токена, оновлюємо store і повторюємо оригінальний запит. Redux Toolkit docs називають цей патерн automatic re-authorization.
Q: Що відбувається з кешем після анмонту компонента?
A: RTK Query запускає відлік keepUnusedDataFor (60 секунд за замовчуванням). Коли він спливає, запис у слайсі видаляється. keepUnusedDataFor: Infinity закріплює дані назавжди, 0 видаляє одразу після анмонту.
Q: (Senior) Як реалізувати infinite scroll в RTK Query без неконтрольованого зростання пам'яті?
A: Використовуй serializeQueryArgs для злиття сторінок в один ключ кешу, merge для додавання нових сторінок і forceRefetch для визначення зміни сторінки. Для контролю пам'яті відслідковуй завантажені номери сторінок і витісняй старі через api.util.updateQueryData або інвалідацію тегів конкретних сторінок, коли вони виходять з viewport.
Приклади
Базовий: API для користувачів з мутацією
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const usersApi = createApi({
reducerPath: 'usersApi',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['User'],
endpoints: (builder) => ({
getUsers: builder.query({
query: () => 'users',
providesTags: ['User'],
}),
updateUser: builder.mutation({
query: ({ id, ...data }) => ({
url: `users/${id}`,
method: 'PUT',
body: data,
}),
invalidatesTags: ['User'], // рефетчить getUsers після оновлення
}),
}),
});
export const { useGetUsersQuery, useUpdateUserMutation } = usersApi;
function UserList() {
const { data, isLoading } = useGetUsersQuery();
const [updateUser] = useUpdateUserMutation();
if (isLoading) return <div>Завантаження...</div>;
return (
<ul>
{data?.map(user => (
<li key={user.id}>
{user.name}
<button onClick={() => updateUser({ id: user.id, name: 'Нове ім\'я' })}>
Перейменувати
</button>
</li>
))}
</ul>
);
}Перойменування користувача інвалідує тег User, тому getUsers автоматично рефетчиться. Ніякого ручного dispatch. Ніякого useEffect для синхронізації стану.
Продакшн-патерн: e-commerce з гранульованими тегами
export const shopApi = createApi({
reducerPath: 'shopApi',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Product', 'Cart'],
endpoints: (builder) => ({
getProducts: builder.query({
query: () => 'products',
providesTags: (result) =>
result
? [
...result.map(({ id }) => ({ type: 'Product', id })),
{ type: 'Product', id: 'LIST' },
]
: [{ type: 'Product', id: 'LIST' }],
}),
addToCart: builder.mutation({
query: ({ productId }) => ({
url: 'cart',
method: 'POST',
body: { productId },
}),
invalidatesTags: ['Cart', { type: 'Product', id: 'LIST' }],
}),
}),
});Гранульовані теги означають, що додавання одного товару до кошика рефетчить список продуктів, але не кожну окрему сторінку товару. Це зменшує зайвий мережевий трафік у великих дашбордах.
Просунутий: умовний запит з polling і трансформацією результату
function AdminPanel({ user }) {
const { activeCount, isFetching } = useGetActiveOrdersQuery(undefined, {
skip: !user?.isAdmin, // запит не йде якщо не адмін
pollingInterval: user?.isAdmin ? 30000 : 0, // опитування кожні 30с
selectFromResult: ({ data, isFetching }) => ({
activeCount: data?.filter(o => o.status === 'active').length ?? 0,
isFetching,
}),
});
return (
<div>
Активні замовлення: {activeCount} {isFetching && '(оновлення)'}
</div>
);
}skip: true повністю скасовує підписку - це зупиняє polling і запобігає витокам пам'яті. selectFromResult виконується після звернення до кешу, тому компонент ре-рендериться тільки коли змінюється activeCount, а не при кожній polling-відповіді.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.