Кастомизация шаблонов

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'
        })
    })
})

Однако в крупных приложениях такой конфигурации недостаточно. Возникают задачи:

  • динамической подстановки URL;
  • централизованной авторизации;
  • повторных запросов;
  • логирования;
  • трансформации payload;
  • генерации endpoint-структур;
  • поддержки нескольких backend;
  • переиспользования шаблонов API;
  • адаптации под SSR;
  • интеграции с websocket;
  • создания собственных DSL поверх RTK Query.

Кастомизация baseQuery

Создание собственного сетевого слоя

fetchBaseQuery — лишь небольшая надстройка над 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
}

Обработка refresh token

Одна из самых распространённых кастомизаций.

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
}

Кастомизация параметров запросов

Динамический baseUrl

const 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 string

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

Кастомизация заголовков

Автоматическая подстановка locale

const baseQuery = fetchBaseQuery({
    baseUrl: '/api',
    prepareHeaders: (headers, { getState }) => {
        const locale = getState().settings.locale

        headers.set('Accept-Language', locale)

        return headers
    }
})

Генерация trace-id

prepareHeaders: (headers) => {
    headers.set(
        'X-Trace-Id',
        crypto.randomUUID()
    )

    return headers
}

Кастомизация структуры ответов

transformResponse

RTK 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)
    }))
}

Кастомизация ошибок

transformErrorResponse

login: builder.mutation({
    query: (credentials) => ({
        url: '/login',
        method: 'POST',
        body: credentials
    }),

    transformErrorResponse: (response) => {
        return {
            code: response.status,
            message: response.data.message
        }
    }
})

Унификация backend-ошибок

Разные backend могут возвращать ошибки в разных форматах:

{
    "error": {
        "message": "Validation failed"
    }
}

или:

{
    "message": "Unauthorized"
}

Кастомизация:

transformErrorResponse: (response) => {
    return {
        status: response.status,
        message:
            response.data?.error?.message ||
            response.data?.message ||
            'Unknown error'
    }
}

Шаблоны endpoint-генераторов

Генерация CRUD endpoint

Повторяющиеся 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')
})

Абстракции над endpoint

Собственный query builder

const createGetEndpoint = (builder, url) => {
    return builder.query({
        query: () => url
    })
}

Использование:

getUsers: createGetEndpoint(builder, '/users'),
getPosts: createGetEndpoint(builder, '/posts')

Шаблон mutation

const createMutationEndpoint = (
    builder,
    url,
    method = 'POST'
) => {
    return builder.mutation({
        query: (body) => ({
            url,
            method,
            body
        })
    })
}

Кастомизация кэширования

Собственная сериализация cache key

RTK Query создаёт cache key автоматически.

Иногда требуется собственная стратегия.

serializeQueryArgs: ({ endpointName, queryArgs }) => {
    return `${endpointName}-${queryArgs.page}`
}

Игнорирование части параметров

serializeQueryArgs: ({ endpointName, queryArgs }) => {
    const { timestamp, ...rest } = queryArgs

    return {
        endpointName,
        ...rest
    }
}

Это позволяет не пересоздавать кэш из-за служебных полей.


Кастомизация merge

Пагинация с объединением данных

getUsers: builder.query({
    query: (page) => `/users?page=${page}`,

    serializeQueryArgs: ({ endpointName }) => {
        return endpointName
    },

    merge: (currentCache, newItems) => {
        currentCache.push(...newItems)
    },

    forceRefetch({ currentArg, previousArg }) {
        return currentArg !== previousArg
    }
})

Кастомизация invalidation

Динамические теги

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 }
    ]
})

Кастомизация retry

Повторные запросы

RTK Query предоставляет встроенный retry.

import {
    retry,
    fetchBaseQuery
} from '@reduxjs/toolkit/query'

const staggeredBaseQuery = retry(
    fetchBaseQuery({
        baseUrl: '/api'
    }),
    {
        maxRetries: 5
    }
)

Условный retry

const customBaseQuery = retry(
    async (args, api, extraOptions) => {
        return rawBaseQuery(args, api, extraOptions)
    },
    {
        maxRetries: 3
    }
)

Кастомизация lifecycle

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()
        }
    }
})

Кастомизация optimistic update

Полный контроль optimistic state

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()
    }
}

Кастомизация websocket-интеграции

onCacheEntryAdded

getNotifications: 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()
    }
})

Кастомизация code splitting

Динамическая инъекция endpoint

export const extendedApi = api.injectEndpoints({
    endpoints: (builder) => ({
        getComments: builder.query({
            query: () => '/comments'
        })
    })
})

Переопределение существующих endpoint

api.injectEndpoints({
    overrideExisting: true,

    endpoints: (builder) => ({
        getUsers: builder.query({
            query: () => '/v2/users'
        })
    })
})

Шаблоны multi-api архитектуры

Несколько backend

export const authApi = createApi({
    reducerPath: 'authApi',
    baseQuery: fetchBaseQuery({
        baseUrl: '/auth'
    }),
    endpoints: () => ({})
})

export const mainApi = createApi({
    reducerPath: 'mainApi',
    baseQuery: fetchBaseQuery({
        baseUrl: '/api'
    }),
    endpoints: () => ({})
})

Универсальный API-конструктор

Фабрика API

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']
})

Кастомизация под SSR

Извлечение состояния

export const api = createApi({
    baseQuery: fetchBaseQuery({
        baseUrl: 'http://localhost:3000'
    }),

    extractRehydrationInfo(action, { reducerPath }) {
        if (action.type === 'HYDRATE') {
            return action.payload[reducerPath]
        }
    },

    endpoints: () => ({})
})

Кастомизация transport layer

Использование Axios

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'
})

Кастомизация генераторов запросов

REST DSL

const restEndpoint = ({
    url,
    method = 'GET'
}) => ({
    query: (body) => ({
        url,
        method,
        body
    })
})

Entity-конструктор

const entityApi = (builder, entity) => ({
    getAll: builder.query({
        query: () => `/${entity}`
    }),

    create: builder.mutation({
        query: (body) => ({
            url: `/${entity}`,
            method: 'POST',
            body
        })
    })
})

Архитектурные паттерны кастомизации

Layered API

Часто RTK Query разделяют на уровни:

transport layer

fetch / axios / graphql

baseQuery layer

auth
retry
logging
headers

endpoint layer

queries
mutations
cache
tags

business layer

hooks
selectors
ui logic

Паттерн composable baseQuery

Обёртки могут комбинироваться.

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-фабрики

Избыточная генерация endpoint приводит к:

  • потере читаемости;
  • сложной отладке;
  • ухудшению DX;
  • трудностям типизации.

Глобальная трансформация всех ответов

Опасный подход:

transformResponse: (response) => {
    return deepNormalize(response)
}

Проблемы:

  • высокая стоимость вычислений;
  • неочевидная структура store;
  • потеря предсказуемости данных.

Практические рекомендации

Централизовать:

  • авторизацию;
  • refresh token;
  • retry;
  • trace-id;
  • обработку ошибок;
  • сериализацию параметров.

Локализовать:

  • transformResponse;
  • optimistic updates;
  • merge-логику;
  • websocket-обновления.

Использовать фабрики только при:

  • большом количестве однотипных endpoint;
  • стабильной структуре backend;
  • наличии единых CRUD-правил.

Избегать чрезмерной магии

Чем сложнее генерация endpoint, тем труднее:

  • понимать API;
  • искать ошибки;
  • поддерживать проект;
  • обучать новых разработчиков.

Баланс между автоматизацией и читаемостью — ключевой принцип кастомизации RTK Query.