Mutation endpoints

В RTK Query mutation endpoints предназначены для изменения данных на сервере. В отличие от query endpoints, которые используются для получения информации, mutation endpoints выполняют операции создания, обновления, удаления и любые другие действия, изменяющие состояние backend-приложения.

Типичные примеры:

  • создание пользователя;
  • авторизация;
  • обновление профиля;
  • удаление записи;
  • отправка формы;
  • загрузка файла;
  • изменение статуса сущности;
  • запуск серверного процесса.

Mutation endpoints описываются внутри секции endpoints при помощи метода builder.mutation.


Базовая структура mutation endpoint

Простейшая мутация выглядит следующим образом:

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

export const api = createApi({
    reducerPath: 'api',
    baseQuery: fetchBaseQuery({
        baseUrl: 'https://api.example.com'
    }),
    endpoints: (builder) => ({
        createPost: builder.mutation({
            query: (newPost) => ({
                url: '/posts',
                method: 'POST',
                body: newPost
            })
        })
    })
})

export const {
    useCreatePostMutation
} = api

Отличия mutation от query

Query

builder.query()

Используется для:

  • GET-запросов;
  • чтения данных;
  • кэширования серверного состояния.

Mutation

builder.mutation()

Используется для:

  • POST;
  • PUT;
  • PATCH;
  • DELETE;
  • операций изменения данных.

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

RTK Query автоматически генерирует hook:

useCreatePostMutation()

В отличие от query hooks, mutation hook возвращает массив:

const [createPost, result] = useCreatePostMutation()

Где:

Элемент Назначение
createPost функция запуска мутации
result объект состояния мутации

Выполнение mutation

POST-запрос

const [createPost] = useCreatePostMutation()

const handleCreate = async () => {
    await createPost({
        title: 'New post',
        content: 'Text'
    })
}

Объект query внутри mutation

Mutation endpoint обычно возвращает объект конфигурации запроса:

query: (data) => ({
    url: '/posts',
    method: 'POST',
    body: data
})

HTTP-методы в mutation endpoints

POST

addUser: builder.mutation({
    query: (user) => ({
        url: '/users',
        method: 'POST',
        body: user
    })
})

PUT

updateUser: builder.mutation({
    query: ({ id, ...user }) => ({
        url: `/users/${id}`,
        method: 'PUT',
        body: user
    })
})

PATCH

patchUser: builder.mutation({
    query: ({ id, ...patch }) => ({
        url: `/users/${id}`,
        method: 'PATCH',
        body: patch
    })
})

DELETE

deleteUser: builder.mutation({
    query: (id) => ({
        url: `/users/${id}`,
        method: 'DELETE'
    })
})

Состояния mutation

Mutation hook предоставляет объект состояния:

const [
    createPost,
    {
        data,
        error,
        isLoading,
        isSuccess,
        isError
    }
] = useCreatePostMutation()

Основные флаги состояния

isLoading

Mutation выполняется в данный момент.

if (isLoading) {
    return <p>Saving...</p>
}

isSuccess

Mutation успешно завершена.

if (isSuccess) {
    console.log('Saved')
}

isError

Во время запроса произошла ошибка.

if (isError) {
    console.error(error)
}

Работа с data

После успешного выполнения серверный ответ попадает в data.

const [login, { data }] = useLoginMutation()
const handleLogin = async () => {
    await login({
        email: 'admin@mail.com',
        password: '123'
    })
}

После выполнения:

console.log(data)

unwrap()

Метод unwrap() преобразует mutation promise в обычный promise.

Без unwrap() RTK Query не выбрасывает ошибку через catch.


Без unwrap

try {
    const result = await createPost(post)

    console.log(result)
} catch (error) {
    console.log(error)
}

С unwrap

try {
    const response = await createPost(post).unwrap()

    console.log(response)
} catch (error) {
    console.error(error)
}

unwrap() особенно полезен:

  • при использовании async/await;
  • для интеграции с try/catch;
  • при обработке ошибок форм;
  • в submit handlers.

Передача аргументов

Mutation принимает один аргумент.


Передача объекта

updatePost: builder.mutation({
    query: ({ id, title, content }) => ({
        url: `/posts/${id}`,
        method: 'PATCH',
        body: {
            title,
            content
        }
    })
})

Вызов

updatePost({
    id: 15,
    title: 'Updated',
    content: 'New content'
})

Query callback внутри mutation

Функция query получает аргумент mutation:

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

Динамические URL

deleteComment: builder.mutation({
    query: (commentId) => ({
        url: `/comments/${commentId}`,
        method: 'DELETE'
    })
})

Передача query params

publishPost: builder.mutation({
    query: ({ id, notify }) => ({
        url: `/posts/${id}`,
        method: 'POST',
        params: {
            notify
        }
    })
})

Передача headers

uploadAvatar: builder.mutation({
    query: (formData) => ({
        url: '/avatar',
        method: 'POST',
        body: formData,
        headers: {
            Authorization: 'Bearer token'
        }
    })
})

Mutation и FormData

RTK Query поддерживает отправку файлов.

uploadFile: builder.mutation({
    query: (file) => {
        const formData = new FormData()

        formData.append('file', file)

        return {
            url: '/upload',
            method: 'POST',
            body: formData
        }
    }
})

Mutation и авторизация

Mutation часто используется для login/logout.


Login endpoint

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

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

const [login, { isLoading }] = useLoginMutation()

const handleSubmit = async () => {
    try {
        const response = await login({
            email: 'admin@mail.com',
            password: '123456'
        }).unwrap()

        console.log(response.token)
    } catch (error) {
        console.error(error)
    }
}

invalidatesTags

Одной из важнейших возможностей mutation endpoints является автоматическая инвалидизация кэша.

После изменения данных RTK Query может автоматически обновить связанные query endpoints.


Пример invalidatesTags

getPosts: builder.query({
    query: () => '/posts',
    providesTags: ['Posts']
})

createPost: builder.mutation({
    query: (post) => ({
        url: '/posts',
        method: 'POST',
        body: post
    }),
    invalidatesTags: ['Posts']
})

Как работает invalidation

Последовательность:

  1. Выполняется mutation.
  2. RTK Query помечает тег как устаревший.
  3. Все query с этим тегом автоматически перезапрашиваются.
  4. UI получает актуальные данные.

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

providesTags

getPosts: builder.query({
    query: () => '/posts',
    providesTags: (result) =>
        result
            ? [
                ...result.map(({ id }) => ({
                    type: 'Posts',
                    id
                })),
                { type: 'Posts', id: 'LIST' }
            ]
            : [{ type: 'Posts', id: 'LIST' }]
})

invalidatesTags

updatePost: builder.mutation({
    query: ({ id, ...patch }) => ({
        url: `/posts/${id}`,
        method: 'PATCH',
        body: patch
    }),
    invalidatesTags: (result, error, arg) => [
        { type: 'Posts', id: arg.id }
    ]
})

Инвалидация списка

createPost: builder.mutation({
    query: (post) => ({
        url: '/posts',
        method: 'POST',
        body: post
    }),
    invalidatesTags: [
        { type: 'Posts', id: 'LIST' }
    ]
})

Оптимистические обновления

RTK Query поддерживает optimistic updates через onQueryStarted.


onQueryStarted

Этот lifecycle callback вызывается сразу после запуска mutation.

updatePost: builder.mutation({
    query: ({ id, ...patch }) => ({
        url: `/posts/${id}`,
        method: 'PATCH',
        body: patch
    }),

    async onQueryStarted(arg, { dispatch, queryFulfilled }) {

    }
})

Optimistic update example

updatePost: builder.mutation({
    query: ({ id, ...patch }) => ({
        url: `/posts/${id}`,
        method: 'PATCH',
        body: patch
    }),

    async onQueryStarted(
        { id, ...patch },
        { dispatch, queryFulfilled }
    ) {

        const patchResult = dispatch(
            api.util.updateQueryData(
                'getPosts',
                undefined,
                (draft) => {
                    const post = draft.find(
                        (item) => item.id === id
                    )

                    if (post) {
                        Object.assign(post, patch)
                    }
                }
            )
        )

        try {
            await queryFulfilled
        } catch {
            patchResult.undo()
        }
    }
})

updateQueryData

Метод:

api.util.updateQueryData()

позволяет изменять кэш query вручную.


Параметры updateQueryData

api.util.updateQueryData(
    endpointName,
    queryArg,
    updateCallback
)

endpointName

Название query endpoint.

'getPosts'

queryArg

Аргумент query.

undefined

updateCallback

Функция изменения draft-state.

(draft) => {
    draft.push(newPost)
}

Rollback optimistic updates

При ошибке можно откатить изменения:

patchResult.undo()

pessimistic updates

Иногда необходимо дождаться ответа сервера.


Пример

createPost: builder.mutation({
    query: (post) => ({
        url: '/posts',
        method: 'POST',
        body: post
    }),

    async onQueryStarted(arg, {
        dispatch,
        queryFulfilled
    }) {

        try {
            const { data: createdPost } =
                await queryFulfilled

            dispatch(
                api.util.updateQueryData(
                    'getPosts',
                    undefined,
                    (draft) => {
                        draft.push(createdPost)
                    }
                )
            )
        } catch (error) {
            console.error(error)
        }
    }
})

transformResponse

Mutation поддерживает преобразование ответа.

createPost: builder.mutation({
    query: (post) => ({
        url: '/posts',
        method: 'POST',
        body: post
    }),

    transformResponse: (response) => {
        return response.data
    }
})

transformErrorResponse

Можно преобразовывать ошибки.

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

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

fixedCacheKey

По умолчанию каждая mutation хранит собственное состояние.

Иногда требуется разделять состояние между компонентами.


Пример fixedCacheKey

const [
    login,
    loginState
] = useLoginMutation({
    fixedCacheKey: 'shared-login'
})

Когда используется fixedCacheKey

Подходит для:

  • глобальных форм;
  • shared loading states;
  • многошаговых процессов;
  • wizard forms;
  • авторизации;
  • модальных окон.

reset()

Mutation state можно очищать вручную.

const [
    createPost,
    { reset }
] = useCreatePostMutation()

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

reset()

Mutation и polling

Mutation endpoints не поддерживают polling, поскольку они не предназначены для постоянного получения данных.

Polling относится только к query endpoints.


Mutation lifecycle

Полный жизненный цикл mutation:

  1. Вызов trigger function.
  2. Dispatch pending action.
  3. Выполнение baseQuery.
  4. Dispatch fulfilled/rejected action.
  5. Обновление store.
  6. Инвалидация тегов.
  7. Перезапрос связанных query.
  8. Обновление UI.

Mutation actions

RTK Query создаёт Redux actions:

api/executeMutation/pending
api/executeMutation/fulfilled
api/executeMutation/rejected

Mutation в Redux DevTools

В DevTools можно отслеживать:

  • payload;
  • status;
  • requestId;
  • cache lifecycle;
  • invalidation;
  • optimistic updates.

Mutation и re-render

Mutation hook вызывает re-render:

  • при старте запроса;
  • при успешном ответе;
  • при ошибке;
  • при reset.

selectFromResult

Можно минимизировать лишние re-render.


Пример

const [updatePost, { isLoading }] =
    useUpdatePostMutation({
        selectFromResult: ({
            isLoading
        }) => ({
            isLoading
        })
    })

Несколько mutation одновременно

Разрешается использовать несколько hooks:

const [createPost] = useCreatePostMutation()
const [deletePost] = useDeletePostMutation()
const [updatePost] = useUpdatePostMutation()

Последовательные mutations

await createPost(post).unwrap()

await publishPost(postId).unwrap()

Параллельные mutations

await Promise.all([
    updatePost(post1),
    updatePost(post2),
    updatePost(post3)
])

Abort mutation

Mutation promise поддерживает abort.

const promise = createPost(post)

promise.abort()

Retry logic

RTK Query может использовать retry wrapper.

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

const baseQuery = retry(
    fetchBaseQuery({
        baseUrl: '/api'
    }),
    {
        maxRetries: 3
    }
)

Mutation и custom baseQuery

Mutation работает с любым baseQuery.


Пример axiosBaseQuery

const axiosBaseQuery =
    ({ baseUrl }) =>
    async ({
        url,
        method,
        data
    }) => {

        try {
            const result = await axios({
                url: baseUrl + url,
                method,
                data
            })

            return {
                data: result.data
            }
        } catch (axiosError) {
            return {
                error: {
                    status:
                        axiosError.response?.status,
                    data:
                        axiosError.response?.data
                }
            }
        }
    }

Mutation с axios

createPost: builder.mutation({
    query: (post) => ({
        url: '/posts',
        method: 'POST',
        data: post
    })
})

Частые ошибки

Использование query вместо mutation

Неправильно:

builder.query({
    query: (post) => ({
        url: '/posts',
        method: 'POST',
        body: post
    })
})

Правильно:

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

Отсутствие invalidatesTags

Без invalidation UI может показывать устаревшие данные.


Отсутствие unwrap()

Без unwrap() сложнее обрабатывать ошибки через try/catch.


Изменение draft вне Immer

Неправильно:

draft = newData

Правильно:

Object.assign(draft, newData)

Архитектурные рекомендации

Разделение endpoints

Хорошая практика:

postsApi
usersApi
commentsApi
authApi

Выделение mutation по бизнес-логике

createPost
updatePost
deletePost
publishPost
archivePost

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

tagTypes: [
    'Posts',
    'Users',
    'Comments'
]

Централизация optimistic updates

Сложную optimistic-логику желательно выносить:

  • в отдельные helper-функции;
  • в utility modules;
  • в cache helpers.

Mutation и SSR

Mutation обычно не используется во время SSR, поскольку изменение данных должно происходить на клиенте после hydration.


Mutation и WebSocket

Mutation endpoints можно комбинировать с realtime-обновлениями:

  • mutation изменяет данные;
  • WebSocket отправляет событие;
  • query cache обновляется через updateQueryData.

Mutation и forms

RTK Query особенно хорошо подходит для форм.


Пример submit handler

const [createUser, {
    isLoading,
    error
}] = useCreateUserMutation()

const handleSubmit = async (values) => {
    try {
        await createUser(values).unwrap()
    } catch (error) {
        console.error(error)
    }
}

Mutation и React Hook Form

const onSub mit = async (data) => {
    await updateProfile(data).unwrap()
}

Mutation и cache synchronization

RTK Query автоматически синхронизирует:

  • query cache;
  • invalidation;
  • subscriptions;
  • UI state;
  • loading states.

Mutation и manual cache updates

Иногда invalidation недостаточно.

В таких случаях используются:

updateQueryData
upsertQueryData
invalidateTags

upsertQueryData

Позволяет создавать или заменять кэш.

dispatch(
    api.util.upsertQueryData(
        'getPost',
        post.id,
        post
    )
)

invalidateTags вручную

dispatch(
    api.util.invalidateTags([
        'Posts'
    ])
)

Mutation endpoint как orchestration layer

Mutation endpoint способен выполнять:

  • optimistic updates;
  • rollback;
  • cache invalidation;
  • side effects;
  • chaining;
  • synchronization;
  • analytics;
  • logging.

Это делает RTK Query полноценной системой управления серверным состоянием, а не просто инструментом для HTTP-запросов.