Breaking changes — это изменения в API, нарушающие совместимость между клиентом и сервером. После внедрения таких изменений старый клиент перестаёт корректно работать без дополнительной адаптации.
Наиболее распространённые примеры:
Пример несовместимого изменения:
Старая версия API:
{
"id": 1,
"name": "Alex"
}
Новая версия API:
{
"id": 1,
"fullName": "Alex"
}
Если RTK Query использует поле name, приложение начнёт
работать некорректно.
RTK Query тесно связан со структурой API, поэтому несовместимые изменения затрагивают:
Особенно опасны изменения в:
transformResponseprovidesTagsinvalidatesTagsupdateQueryDataonQueryStartedserializeQueryArgsСуществует несколько подходов:
Наиболее надёжный подход.
Пример:
/api/v1/users
/api/v2/users
RTK Query позволяет поддерживать несколько версий одновременно.
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export const apiV1 = createApi({
reducerPath: 'apiV1',
baseQuery: fetchBaseQuery({
baseUrl: '/api/v1'
}),
endpoints: (builder) => ({
getUsers: builder.query({
query: () => '/users'
})
})
})
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export const apiV2 = createApi({
reducerPath: 'apiV2',
baseQuery: fetchBaseQuery({
baseUrl: '/api/v2'
}),
endpoints: (builder) => ({
getUsers: builder.query({
query: () => '/users'
})
})
})
RTK Query позволяет использовать обе версии одновременно.
const { data: oldUsers } = apiV1.useGetUsersQuery()
const { data: newUsers } = apiV2.useGetUsersQuery()
Такой подход полезен при:
Breaking changes часто касаются структуры данных.
Сервер v1:
{
"data": [
{
"id": 1,
"name": "John"
}
]
}
Сервер v2:
{
"result": [
{
"id": 1,
"full_name": "John"
}
]
}
Вместо изменения всего приложения удобнее выполнить нормализацию.
getUsers: builder.query({
query: () => '/users',
transformResponse: (response) => {
return response.result.map(user => ({
id: user.id,
name: user.full_name
}))
}
})
Теперь приложение продолжает работать со старой моделью:
user.name
несмотря на изменения API.
Иногда сервер возвращает разные структуры во время миграции.
transformResponse: (response) => {
const users = response.data || response.result || []
return users.map(user => ({
id: user.id,
name: user.name || user.full_name
}))
}
Это снижает риск массовых ошибок при поэтапном обновлении backend.
Крупные проекты часто используют слой адаптации.
function mapUser(user) {
return {
id: user.id,
name: user.name || user.full_name,
email: user.email
}
}
RTK Query:
transformResponse: (response) => {
return response.users.map(mapUser)
}
Преимущества:
Сервер:
{
"id": 1
}
Фронтенд ожидает:
user.avatar
Без обработки:
Cannot read properties of undefined
transformResponse: (response) => {
return {
...response,
avatar: response.avatar || null
}
}
const avatar = user?.avatar ?? '/default-avatar.png'
Старая версия:
{
"id": 1
}
Новая версия:
{
"id": "1"
}
Проблемы:
transformResponse: (response) => {
return {
...response,
id: Number(response.id)
}
}
Старая версия:
{
"items": [],
"total": 100
}
Новая версия:
{
"data": [],
"meta": {
"count": 100
}
}
transformResponse: (response) => {
return {
items: response.items || response.data,
total: response.total || response.meta?.count || 0
}
}
Старая версия:
GET /users/delete/1
Новая версия:
DELETE /users/1
RTK Query:
deleteUser: builder.mutation({
query: (id) => ({
url: `/users/${id}`,
method: 'DELETE'
})
})
deleteUserLegacy: builder.mutation({
query: (id) => ({
url: `/users/delete/${id}`,
method: 'GET'
})
})
const USE_NEW_API = true
getUsers: builder.query({
query: () => {
return USE_NEW_API
? '/v2/users'
: '/v1/users'
}
})
Иногда сервер меняется раньше клиента.
RTK Query можно адаптировать динамически.
transformResponse: (response) => {
if (response.result) {
return response.result
}
return response.data
}
Старый формат:
{
"message": "Validation failed"
}
Новый формат:
{
"error": {
"text": "Validation failed"
}
}
transformErrorResponse: (response) => {
return {
message:
response.data?.message ||
response.data?.error?.text ||
'Unknown error'
}
}
Старая схема:
Authorization: Token abc123
Новая схема:
Authorization: Bearer abc123
baseQuery: fetchBaseQuery({
baseUrl: '/api',
prepareHeaders: (headers, { getState }) => {
const token = getState().auth.token
headers.set('Authorization', `Bearer ${token}`)
return headers
}
})
Иногда breaking changes касаются транспортного уровня.
Пример:
const customBaseQuery = async (args, api, extraOptions) => {
const response = await fetch(args.url)
const data = await response.json()
return {
data
}
}
const graphqlBaseQuery = async ({ body }) => {
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
})
const result = await response.json()
return {
data: result.data
}
}
Изменение структуры данных может ломать кеш.
Проблемный пример:
providesTags: (result) =>
result.map(user => ({
type: 'Users',
id: user.id
}))
Если user.id изменил тип:
1 !== "1"
invalidatesTags перестанет работать корректно.
providesTags: (result) =>
result.map(user => ({
type: 'Users',
id: String(user.id)
}))
Изменение query parameters способно сломать кеширование.
Старая версия:
/users?page=1
Новая версия:
/users?page[number]=1
serializeQueryArgs: ({ endpointName, queryArgs }) => {
return `${endpointName}-${queryArgs.page}`
}
Breaking changes часто затрагивают mutations.
updateQueryData('getUsers', undefined, draft => {
draft.push(newUser)
})
Если сервер изменил структуру:
{
items: []
}
код перестанет работать.
transformResponse: (response) => {
return {
items: response.items || response.data || []
}
}
updateQueryData('getUsers', undefined, draft => {
draft.items.push(newUser)
})
Breaking changes особенно опасны при использовании
createEntityAdapter.
Старая версия:
{
id: 1
}
Новая версия:
{
uuid: 'abc'
}
Adapter:
selectId: (user) => user.id
const usersAdapter = createEntityAdapter({
selectId: (user) => user.id || user.uuid
})
Иногда backend обновляется постепенно.
Один сервер:
{
"name": "Alex"
}
Другой:
{
"full_name": "Alex"
}
function normalizeUser(user) {
return {
id: user.id,
name: user.name || user.full_name || 'Unknown'
}
}
RTK Query может поддерживать deprecated endpoints.
export const legacyApi = createApi({
reducerPath: 'legacyApi',
baseQuery: fetchBaseQuery({
baseUrl: '/legacy-api'
}),
endpoints: (builder) => ({
getUsers: builder.query({
query: () => '/users'
})
})
})
Иногда удобно скрыть breaking changes внутри frontend.
getUsers: builder.query({
async queryFn(arg, api, extraOptions, baseQuery) {
const result = await baseQuery('/v2/users')
if (result.error) {
return result
}
return {
data: result.data.map(user => ({
id: user.id,
name: user.full_name
}))
}
}
})
Некоторые API используют заголовки версий.
Accept-Version: 2
prepareHeaders: (headers) => {
headers.set('Accept-Version', '2')
return headers
}
const detectVersion = (response) => {
if (response.result) {
return 2
}
return 1
}
При breaking changes важно избегать полного падения интерфейса.
if (!user.name) {
return <span>User unavailable</span>
}
Старая версия:
{
"status": "active"
}
Новая версия:
{
"status": "enabled"
}
const statusMap = {
active: 'active',
enabled: 'active'
}
transformResponse: (response) => {
return {
...response,
status: statusMap[response.status]
}
}
Критически важно тестировать:
expect(normalizeUser(apiResponse)).toMatchSnapshot()
expect(user).toHaveProperty('id')
expect(user).toHaveProperty('name')
const result = await store.dispatch(
api.endpoints.getUsers.initiate()
)
expect(result.data.length).toBeGreaterThan(0)
Breaking changes невозможно полностью исключить, поэтому RTK Query требует defensive-подхода:
Наиболее устойчивая архитектура RTK Query обычно содержит:
Изолирует HTTP и transport logic.
Нормализует данные.
Содержит adapters и selectors.
Работает только со стабильной моделью данных.
function normalizeUser(rawUser) {
return {
id: String(rawUser.id || rawUser.uuid),
name:
rawUser.name ||
rawUser.full_name ||
'Unknown',
avatar:
rawUser.avatar ||
rawUser.photo ||
null
}
}
RTK Query endpoint:
getUsers: builder.query({
query: () => '/users',
transformResponse: (response) => {
const users =
response.data ||
response.result ||
response.users ||
[]
return users.map(normalizeUser)
},
providesTags: (result) =>
result.map(user => ({
type: 'Users',
id: user.id
}))
})
Такая схема значительно снижает влияние breaking changes на frontend-приложение и позволяет мигрировать API постепенно без массового переписывания компонентов.