В основе работы RTK Query лежит механизм baseQuery —
функция, отвечающая за выполнение HTTP-запросов. По умолчанию чаще всего
используется fetchBaseQuery, представляющая собой тонкую
обёртку над стандартным fetch.
Базовая конфигурация обычно выглядит так:
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({
baseUrl: 'https://api.example.com',
}),
endpoints: (builder) => ({
getUsers: builder.query({
query: () => '/users',
}),
}),
});
Однако стандартного fetchBaseQuery недостаточно в
сложных приложениях, где требуется:
В подобных случаях создаются кастомные baseQuery
функции.
baseQueryЛюбая baseQuery функция получает три аргумента:
const customBaseQuery = async (
args,
api,
extraOptions
) => {
};
argsСодержит параметры запроса, переданные из query.
Пример:
getUsers: builder.query({
query: () => ({
url: '/users',
method: 'GET',
}),
}),
Внутри baseQuery:
args = {
url: '/users',
method: 'GET',
}
apiПредоставляет доступ к внутренним возможностям RTK Query.
Основные свойства:
api.dispatch
api.getState
api.signal
api.endpoint
api.type
dispatchПозволяет отправлять Redux actions.
api.dispatch(logout());
getStateДоступ к Redux store.
const state = api.getState();
const token = state.auth.token;
signalОбъект AbortSignal для отмены запросов.
signal.addEventListener('abort', () => {
console.log('Request aborted');
});
endpointИмя endpoint.
console.log(api.endpoint);
typeТип операции:
'query'
'mutation'
extraOptionsИспользуется реже. Позволяет передавать дополнительные параметры в
baseQuery.
baseQueryФункция обязана возвращать объект строго определённого формата.
return {
data: result,
};
return {
error: {
status: 500,
data: 'Server Error',
},
};
baseQueryfetchconst customBaseQuery = async (args) => {
try {
const response = await fetch(args.url);
const data = await response.json();
return {
data,
};
} catch (error) {
return {
error: {
status: 'FETCH_ERROR',
data: error.message,
},
};
}
};
Подключение:
export const api = createApi({
reducerPath: 'api',
baseQuery: customBaseQuery,
endpoints: (builder) => ({
getUsers: builder.query({
query: () => ({
url: '/users',
}),
}),
}),
});
baseQuery поверх fetchBaseQueryНаиболее распространённый подход — не переписывать весь механизм
запросов, а расширять стандартный fetchBaseQuery.
const baseQuery = fetchBaseQuery({
baseUrl: 'https://api.example.com',
});
const customBaseQuery = async (
args,
api,
extraOptions
) => {
const result = await baseQuery(
args,
api,
extraOptions
);
return result;
};
Подобная архитектура позволяет внедрять дополнительную логику между отправкой запроса и возвратом результата.
Одна из главных задач кастомного baseQuery —
централизованная авторизация.
const customBaseQuery = async (
args,
api,
extraOptions
) => {
const state = api.getState();
const token = state.auth.token;
const headers = {
...args.headers,
Authorization: `Bearer ${token}`,
};
const result = await fetchBaseQuery({
baseUrl: 'https://api.example.com',
})(
{
...args,
headers,
},
api,
extraOptions
);
return result;
};
prepareHeadersВо многих случаях токены удобнее внедрять через
prepareHeaders.
const baseQuery = fetchBaseQuery({
baseUrl: 'https://api.example.com',
prepareHeaders: (headers, { getState }) => {
const token = getState().auth.token;
if (token) {
headers.set(
'Authorization',
`Bearer ${token}`
);
}
return headers;
},
});
Но кастомный baseQuery остаётся необходимым, если логика
становится сложнее.
Один из важнейших сценариев.
401 UnauthorizedfetchBaseQueryconst baseQuery = fetchBaseQuery({
baseUrl: 'https://api.example.com',
});
const baseQueryWithReauth = async (
args,
api,
extraOptions
) => {
let result = await baseQuery(
args,
api,
extraOptions
);
if (result.error?.status === 401) {
const refreshResult = await baseQuery(
{
url: '/auth/refresh',
method: 'POST',
},
api,
extraOptions
);
if (refreshResult.data) {
api.dispatch(
setCredentials(refreshResult.data)
);
result = await baseQuery(
args,
api,
extraOptions
);
} else {
api.dispatch(logout());
}
}
return result;
};
При параллельных запросах может возникнуть проблема:
401;Часто используется библиотека:
async-mutex
import { Mutex } from 'async-mutex';
const mutex = new Mutex();
const baseQueryWithReauth = async (
args,
api,
extraOptions
) => {
await mutex.waitForUnlock();
let result = await baseQuery(
args,
api,
extraOptions
);
if (result.error?.status === 401) {
if (!mutex.isLocked()) {
const release = await mutex.acquire();
try {
const refreshResult = await baseQuery(
{
url: '/auth/refresh',
method: 'POST',
},
api,
extraOptions
);
if (refreshResult.data) {
api.dispatch(
setCredentials(refreshResult.data)
);
result = await baseQuery(
args,
api,
extraOptions
);
} else {
api.dispatch(logout());
}
} finally {
release();
}
} else {
await mutex.waitForUnlock();
result = await baseQuery(
args,
api,
extraOptions
);
}
}
return result;
};
Кастомный baseQuery позволяет унифицировать обработку
ошибок.
const customBaseQuery = async (
args,
api,
extraOptions
) => {
const result = await baseQuery(
args,
api,
extraOptions
);
if (result.error) {
switch (result.error.status) {
case 403:
console.error('Access denied');
break;
case 500:
console.error('Server error');
break;
default:
console.error('Unknown error');
}
}
return result;
};
Часто ошибки показываются через toast-уведомления.
if (result.error) {
showToast({
type: 'error',
message: 'Request failed',
});
}
console.log('Request:', args);
console.log('Response:', result);
const start = performance.now();
const result = await baseQuery(
args,
api,
extraOptions
);
const end = performance.now();
console.log(`Request time: ${end - start}ms`);
retry)Иногда запрос необходимо повторить автоматически.
const retryBaseQuery = async (
args,
api,
extraOptions
) => {
let attempts = 3;
while (attempts > 0) {
const result = await baseQuery(
args,
api,
extraOptions
);
if (!result.error) {
return result;
}
attempts--;
}
return {
error: {
status: 'RETRY_FAILED',
},
};
};
Более корректный retry-механизм:
const sleep = (ms) =>
new Promise((resolve) =>
setTimeout(resolve, ms)
);
let delay = 1000;
while (attempts > 0) {
const result = await baseQuery(
args,
api,
extraOptions
);
if (!result.error) {
return result;
}
await sleep(delay);
delay *= 2;
attempts--;
}
Стандартный fetch не имеет встроенного timeout.
AbortControllerconst customBaseQuery = async (args) => {
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, 5000);
try {
const response = await fetch(
args.url,
{
signal: controller.signal,
}
);
clearTimeout(timeout);
return {
data: await response.json(),
};
} catch (error) {
return {
error: {
status: 'TIMEOUT_ERROR',
data: error.message,
},
};
}
};
RTK Query не привязан к fetch.
import axios from 'axios';
const axiosBaseQuery =
(
{ baseUrl } = { baseUrl: '' }
) =>
async ({
url,
method,
data,
params,
headers,
}) => {
try {
const result = await axios({
url: baseUrl + url,
method,
data,
params,
headers,
});
return {
data: result.data,
};
} catch (axiosError) {
return {
error: {
status: axiosError.response?.status,
data: axiosError.response?.data,
},
};
}
};
export const api = createApi({
reducerPath: 'api',
baseQuery: axiosBaseQuery({
baseUrl: 'https://api.example.com',
}),
endpoints: (builder) => ({
getUsers: builder.query({
query: () => ({
url: '/users',
method: 'GET',
}),
}),
}),
});
baseQueryaxios.interceptors.request.use();
axios.interceptors.response.use();
Axios автоматически преобразует JSON.
timeout: 5000
signal: api.signal
RTK Query может использоваться с GraphQL API.
Часто используются:
graphql-requestimport { GraphQLClient } from 'graphql-request';
const client = new GraphQLClient(
'https://graphql.example.com'
);
const graphqlBaseQuery =
({ baseUrl }) =>
async ({ body }) => {
try {
const result =
await client.request(body);
return {
data: result,
};
} catch (error) {
return {
error: {
status: error.response.status,
data: error,
},
};
}
};
getPosts: builder.query({
query: () => ({
body: `
query {
posts {
id
title
}
}
`,
}),
}),
baseUrlИногда сервер выбирается во время выполнения.
const dynamicBaseQuery = async (
args,
api,
extraOptions
) => {
const state = api.getState();
const baseUrl =
state.settings.apiUrl;
const rawBaseQuery = fetchBaseQuery({
baseUrl,
});
return rawBaseQuery(
args,
api,
extraOptions
);
};
const baseUrl =
args.service === 'users'
? 'https://users.api.com'
: 'https://orders.api.com';
Кастомный baseQuery позволяет изменять запросы до
отправки.
args.body = {
...args.body,
timestamp: Date.now(),
};
if (result.data) {
result.data = normalizeData(
result.data
);
}
Иногда API требует зашифрованные payload.
args.body = encrypt(args.body);
result.data = decrypt(result.data);
uploadFile: builder.mutation({
query: (file) => {
const formData = new FormData();
formData.append('file', file);
return {
url: '/upload',
method: 'POST',
body: formData,
};
},
}),
Content-TypeПри использовании FormData нельзя вручную указывать:
Content-Type: multipart/form-data
Иначе boundary будет сформирован неправильно.
const response = await fetch(args.url);
const blob = await response.blob();
Иногда API требует нестандартный query string.
const params = new URLSearchParams();
params.append('ids[]', 1);
params.append('ids[]', 2);
params.append('ids[]', 3);
RTK Query может использовать кастомный baseQuery даже
для WebSocket-коммуникаций.
const websocketBaseQuery =
() =>
async ({ message }) => {
return new Promise((resolve) => {
socket.send(message);
socket.onmess age = (event) => {
resolve({
data: JSON.parse(event.data),
});
};
});
};
baseQueryМожно создавать цепочки обработчиков.
const withLogger =
(baseQuery) =>
async (args, api, extraOptions) => {
console.log('Request started');
const result = await baseQuery(
args,
api,
extraOptions
);
console.log('Request ended');
return result;
};
const enhancedBaseQuery =
withLogger(
withRetry(
withAuth(baseQuery)
)
);
baseQueryПри использовании TypeScript желательно явно указывать типы.
import {
BaseQueryFn,
FetchArgs,
FetchBaseQueryError,
} from '@reduxjs/toolkit/query';
const customBaseQuery: BaseQueryFn<
string | FetchArgs,
unknown,
FetchBaseQueryError
> = async (
args,
api,
extraOptions
) => {
return fetchBaseQuery({
baseUrl: '/api',
})(
args,
api,
extraOptions
);
};
type CustomError = {
status: number;
message: string;
};
BaseQueryFn<
FetchArgs,
UserResponse,
CustomError
>
fetchBaseQueryПлохо:
const customBaseQuery = async (
args,
api,
extraOptions
) => {
const rawBaseQuery =
fetchBaseQuery({
baseUrl: '/api',
});
return rawBaseQuery(
args,
api,
extraOptions
);
};
Проблема — новый экземпляр создаётся при каждом запросе.
const rawBaseQuery = fetchBaseQuery({
baseUrl: '/api',
});
Создание должно происходить один раз вне функции.
if (result.error?.status === 'FETCH_ERROR') {
}
if (result.error?.status === 'PARSING_ERROR') {
}
if (result.error?.status === 'TIMEOUT_ERROR') {
}
На практике крупные приложения часто используют следующую структуру:
const rawBaseQuery = fetchBaseQuery({
baseUrl: '/api',
prepareHeaders,
});
const baseQueryWithAuth =
withAuth(rawBaseQuery);
const baseQueryWithRetry =
withRetry(baseQueryWithAuth);
const baseQueryWithLogger =
withLogger(baseQueryWithRetry);
export const api = createApi({
reducerPath: 'api',
baseQuery: baseQueryWithLogger,
endpoints: () => ({}),
});
Подобный подход делает систему: