RTK Query позволяет полностью переопределять поведение сетевого слоя,
механизмов сериализации, генерации запросов, кэширования и обработки
ответов. Под кастомизацией шаблонов обычно понимается изменение
стандартных механизмов createApi, baseQuery,
fetchBaseQuery, генераторов endpoint-структур, поведения
хуков и общей архитектуры API-слоя.
Стандартная конфигурация RTK Query выглядит минималистично:
import { createApi, fetchBaseQuery } fr om '@reduxjs/toolkit/query/react'
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({
baseUrl: '/api'
}),
endpoints: (builder) => ({
getUsers: builder.query({
query: () => '/users'
})
})
})
Однако в крупных приложениях такой конфигурации недостаточно. Возникают задачи:
baseQueryfetchBaseQuery — лишь небольшая надстройка над
fetch. RTK Query допускает использование любой функции,
соответствующей интерфейсу baseQuery.
Простейший кастомный baseQuery:
const customBaseQuery = async (args, api, extraOptions) => {
try {
const response = await fetch(args.url)
const data = await response.json()
return { data }
} catch (error) {
return {
error: {
status: 'CUSTOM_ERROR',
error: error.message
}
}
}
}
Использование:
export const api = createApi({
reducerPath: 'api',
baseQuery: customBaseQuery,
endpoints: () => ({})
})
fetchBaseQueryЧаще всего кастомизация строится поверх
fetchBaseQuery.
import { fetchBaseQuery } fr om '@reduxjs/toolkit/query'
const rawBaseQuery = fetchBaseQuery({
baseUrl: '/api'
})
const baseQueryWithAuth = async (args, api, extraOptions) => {
const token = api.getState().auth.token
const headers = {
Authorization: `Bearer ${token}`
}
if (typeof args === 'string') {
args = {
url: args,
headers
}
} else {
args.headers = {
...args.headers,
...headers
}
}
return rawBaseQuery(args, api, extraOptions)
}
const baseQueryWithLogger = async (args, api, extraOptions) => {
console.log('REQUEST:', args)
const result = await rawBaseQuery(args, api, extraOptions)
console.log('RESPONSE:', result)
return result
}
Одна из самых распространённых кастомизаций.
const rawBaseQuery = fetchBaseQuery({
baseUrl: '/api'
})
const baseQueryWithReauth = async (args, api, extraOptions) => {
let result = await rawBaseQuery(args, api, extraOptions)
if (result.error?.status === 401) {
const refreshResult = await rawBaseQuery(
{
url: '/auth/refresh',
method: 'POST'
},
api,
extraOptions
)
if (refreshResult.data) {
api.dispatch(setCredentials(refreshResult.data))
result = await rawBaseQuery(args, api, extraOptions)
} else {
api.dispatch(logout())
}
}
return result
}
baseUrlconst dynamicBaseQuery = async (args, api, extraOptions) => {
const tenant = api.getState().tenant.current
const dynamicUrl = `https://${tenant}.example.com/api`
const rawBaseQuery = fetchBaseQuery({
baseUrl: dynamicUrl
})
return rawBaseQuery(args, api, extraOptions)
}
query: (params) => ({
url: '/users',
params: {
page: params.page,
lim it: params.lim it,
search: params.search
}
})
RTK Query автоматически сериализует параметры:
/users?page=1&limit=20&search=admin
Иногда стандартная сериализация не подходит.
const baseQuery = fetchBaseQuery({
baseUrl: '/api',
paramsSerializer: (params) => {
return Object.entries(params)
.map(([key, value]) => {
if (Array.isArray(value)) {
return value.map(v => `${key}[]=${v}`).join('&')
}
return `${key}=${value}`
})
.join('&')
}
})
Результат:
/users?roles[]=admin&roles[]=manager
const baseQuery = fetchBaseQuery({
baseUrl: '/api',
prepareHeaders: (headers, { getState }) => {
const locale = getState().settings.locale
headers.set('Accept-Language', locale)
return headers
}
})
prepareHeaders: (headers) => {
headers.set(
'X-Trace-Id',
crypto.randomUUID()
)
return headers
}
transformResponseRTK Query позволяет преобразовывать серверные ответы до попадания в store.
Ответ сервера:
{
"result": {
"items": []
}
}
Трансформация:
getUsers: builder.query({
query: () => '/users',
transformResponse: (response) => {
return response.result.items
}
})
transformResponse: (response) => {
return response.reduce((acc, item) => {
acc[item.id] = item
return acc
}, {})
}
transformResponse: (response) => {
return response.map(item => ({
...item,
createdAt: new Date(item.createdAt)
}))
}
transformErrorResponselogin: builder.mutation({
query: (credentials) => ({
url: '/login',
method: 'POST',
body: credentials
}),
transformErrorResponse: (response) => {
return {
code: response.status,
message: response.data.message
}
}
})
Разные backend могут возвращать ошибки в разных форматах:
{
"error": {
"message": "Validation failed"
}
}
или:
{
"message": "Unauthorized"
}
Кастомизация:
transformErrorResponse: (response) => {
return {
status: response.status,
message:
response.data?.error?.message ||
response.data?.message ||
'Unknown error'
}
}
Повторяющиеся CRUD-конструкции удобно выносить в фабрики.
const createCrudEndpoints = (builder, entity) => ({
[`get${entity}List`]: builder.query({
query: () => `/${entity}`
}),
[`get${entity}ById`]: builder.query({
query: (id) => `/${entity}/${id}`
}),
[`create${entity}`]: builder.mutation({
query: (body) => ({
url: `/${entity}`,
method: 'POST',
body
})
}),
[`update${entity}`]: builder.mutation({
query: ({ id, ...body }) => ({
url: `/${entity}/${id}`,
method: 'PUT',
body
})
}),
[`delete${entity}`]: builder.mutation({
query: (id) => ({
url: `/${entity}/${id}`,
method: 'DELETE'
})
})
})
Использование:
endpoints: (builder) => ({
...createCrudEndpoints(builder, 'users'),
...createCrudEndpoints(builder, 'posts')
})
const createGetEndpoint = (builder, url) => {
return builder.query({
query: () => url
})
}
Использование:
getUsers: createGetEndpoint(builder, '/users'),
getPosts: createGetEndpoint(builder, '/posts')
const createMutationEndpoint = (
builder,
url,
method = 'POST'
) => {
return builder.mutation({
query: (body) => ({
url,
method,
body
})
})
}
RTK Query создаёт cache key автоматически.
Иногда требуется собственная стратегия.
serializeQueryArgs: ({ endpointName, queryArgs }) => {
return `${endpointName}-${queryArgs.page}`
}
serializeQueryArgs: ({ endpointName, queryArgs }) => {
const { timestamp, ...rest } = queryArgs
return {
endpointName,
...rest
}
}
Это позволяет не пересоздавать кэш из-за служебных полей.
getUsers: builder.query({
query: (page) => `/users?page=${page}`,
serializeQueryArgs: ({ endpointName }) => {
return endpointName
},
merge: (currentCache, newItems) => {
currentCache.push(...newItems)
},
forceRefetch({ currentArg, previousArg }) {
return currentArg !== previousArg
}
})
getUsers: builder.query({
query: () => '/users',
providesTags: (result) =>
result
? [
...result.map(({ id }) => ({
type: 'Users',
id
})),
{ type: 'Users', id: 'LIST' }
]
: [{ type: 'Users', id: 'LIST' }]
})
updateUser: builder.mutation({
query: ({ id, ...body }) => ({
url: `/users/${id}`,
method: 'PATCH',
body
}),
invalidatesTags: (result, error, arg) => [
{ type: 'Users', id: arg.id }
]
})
RTK Query предоставляет встроенный retry.
import {
retry,
fetchBaseQuery
} from '@reduxjs/toolkit/query'
const staggeredBaseQuery = retry(
fetchBaseQuery({
baseUrl: '/api'
}),
{
maxRetries: 5
}
)
const customBaseQuery = retry(
async (args, api, extraOptions) => {
return rawBaseQuery(args, api, extraOptions)
},
{
maxRetries: 3
}
)
onQueryStartedПозволяет внедрять собственную бизнес-логику.
updateUser: builder.mutation({
query: ({ id, ...patch }) => ({
url: `/users/${id}`,
method: 'PATCH',
body: patch
}),
async onQueryStarted(arg, { dispatch, queryFulfilled }) {
const patchResult = dispatch(
api.util.updateQueryData(
'getUsers',
undefined,
(draft) => {
const user = draft.find(
item => item.id === arg.id
)
Object.assign(user, patch)
}
)
)
try {
await queryFulfilled
} catch {
patchResult.undo()
}
}
})
async onQueryStarted(
{ id, title },
{ dispatch, queryFulfilled }
) {
const patchResult = dispatch(
api.util.updateQueryData(
'getPosts',
undefined,
(draft) => {
const post = draft.find(
p => p.id === id
)
if (post) {
post.title = title
}
}
)
)
try {
await queryFulfilled
} catch {
patchResult.undo()
}
}
onCacheEntryAddedgetNotifications: builder.query({
query: () => '/notifications',
async onCacheEntryAdded(
arg,
{
updateCachedData,
cacheDataLoaded,
cacheEntryRemoved
}
) {
await cacheDataLoaded
const socket = new WebSocket(
'wss://example.com'
)
socket.addEventListener('message', (event) => {
const data = JSON.parse(event.data)
updateCachedData((draft) => {
draft.push(data)
})
})
await cacheEntryRemoved
socket.close()
}
})
export const extendedApi = api.injectEndpoints({
endpoints: (builder) => ({
getComments: builder.query({
query: () => '/comments'
})
})
})
api.injectEndpoints({
overrideExisting: true,
endpoints: (builder) => ({
getUsers: builder.query({
query: () => '/v2/users'
})
})
})
export const authApi = createApi({
reducerPath: 'authApi',
baseQuery: fetchBaseQuery({
baseUrl: '/auth'
}),
endpoints: () => ({})
})
export const mainApi = createApi({
reducerPath: 'mainApi',
baseQuery: fetchBaseQuery({
baseUrl: '/api'
}),
endpoints: () => ({})
})
const createBaseApi = ({
reducerPath,
baseUrl,
tagTypes = []
}) => {
return createApi({
reducerPath,
baseQuery: fetchBaseQuery({
baseUrl
}),
tagTypes,
endpoints: () => ({})
})
}
Использование:
export const usersApi = createBaseApi({
reducerPath: 'usersApi',
baseUrl: '/users-api',
tagTypes: ['Users']
})
export const postsApi = createBaseApi({
reducerPath: 'postsApi',
baseUrl: '/posts-api',
tagTypes: ['Posts']
})
export const api = createApi({
baseQuery: fetchBaseQuery({
baseUrl: 'http://localhost:3000'
}),
extractRehydrationInfo(action, { reducerPath }) {
if (action.type === 'HYDRATE') {
return action.payload[reducerPath]
}
},
endpoints: () => ({})
})
RTK Query не ограничивается fetch.
import axios from '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) {
return {
error: {
status: axiosError.response?.status,
data: axiosError.response?.data
}
}
}
}
Использование:
baseQuery: axiosBaseQuery({
baseUrl: '/api'
})
const restEndpoint = ({
url,
method = 'GET'
}) => ({
query: (body) => ({
url,
method,
body
})
})
const entityApi = (builder, entity) => ({
getAll: builder.query({
query: () => `/${entity}`
}),
create: builder.mutation({
query: (body) => ({
url: `/${entity}`,
method: 'POST',
body
})
})
})
Часто RTK Query разделяют на уровни:
fetch / axios / graphql
auth
retry
logging
headers
queries
mutations
cache
tags
hooks
selectors
ui logic
Обёртки могут комбинироваться.
const withLogger = (baseQuery) => {
return async (args, api, extraOptions) => {
console.log(args)
return baseQuery(args, api, extraOptions)
}
}
const withAuth = (baseQuery) => {
return async (args, api, extraOptions) => {
const token = api.getState().auth.token
args.headers = {
...args.headers,
Authorization: `Bearer ${token}`
}
return baseQuery(args, api, extraOptions)
}
}
Композиция:
const baseQuery = withLogger(
withAuth(
fetchBaseQuery({
baseUrl: '/api'
})
)
)
fetchBaseQuery внутри запросаПлохой вариант:
const baseQuery = async (args, api, extraOptions) => {
const dynamic = fetchBaseQuery({
baseUrl: '/api'
})
return dynamic(args, api, extraOptions)
}
Проблемы:
Избыточная генерация endpoint приводит к:
Опасный подход:
transformResponse: (response) => {
return deepNormalize(response)
}
Проблемы:
transformResponse;Чем сложнее генерация endpoint, тем труднее:
Баланс между автоматизацией и читаемостью — ключевой принцип кастомизации RTK Query.