Skip to main content

RTK Query Mutation Best Practices

RTK Query mutations are type-safe, auto-generated hooks that handle fetch logic, caching, loading states, and error management. Unlike SWR's manual fetch() then mutate() pattern, RTK Query's mutations integrate seamlessly with your cache: invalidate related queries automatically, apply optimistic updates safely, and roll back on errors. This guide covers defining mutations, orchestrating optimistic updates, implementing error recovery, and handling complex side effects—patterns that scale across hundreds of endpoints without repetition.

Defining a Basic Mutation

Mutations are builder endpoints with a request body. Define them in your API slice:

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

interface Post {
id: number;
title: string;
content: string;
}

export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: 'https://api.example.com' }),
tagTypes: ['Post'],
endpoints: (builder) => ({
createPost: builder.mutation<Post, { title: string; content: string }>({
query: (newPost) => ({
url: '/posts',
method: 'POST',
body: newPost,
}),
invalidatesTags: ['Post'],
}),

updatePost: builder.mutation<Post, { id: number; title: string }>({
query: ({ id, ...patch }) => ({
url: `/posts/${id}`,
method: 'PATCH',
body: patch,
}),
invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }],
}),

deletePost: builder.mutation<void, number>({
query: (id) => ({
url: `/posts/${id}`,
method: 'DELETE',
}),
invalidatesTags: ['Post'],
}),
}),
});

export const {
useCreatePostMutation,
useUpdatePostMutation,
useDeletePostMutation,
} = api;

Each mutation automatically generates a hook. Use it in a component:

export function CreatePostForm() {
const [createPost, { isLoading, error }] = useCreatePostMutation();

async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);

try {
const newPost = await createPost({
title: formData.get('title') as string,
content: formData.get('content') as string,
}).unwrap(); // unwrap() rejects on error (good for try/catch)

console.log('Created:', newPost);
e.currentTarget.reset();
} catch (error) {
console.error('Failed to create post:', error);
}
}

return (
<form onSubmit={handleSubmit}>
<input type="text" name="title" required />
<textarea name="content" required />
<button type="submit" disabled={isLoading}>
{isLoading ? 'Creating...' : 'Create'}
</button>
{error && <div className="error">{(error as any).message}</div>}
</form>
);
}

The hook returns [trigger, { isLoading, error, data }]. Call trigger() to fire the mutation, and .unwrap() to convert the promise chain to throw-on-error (cleaner than handling fulfilled/rejected states).

Optimistic Updates with onQueryStarted

RTK Query provides onQueryStarted to update the cache before the server responds. Wrap updates in a try/catch to roll back on error:

export const api = createApi({
// ...
endpoints: (builder) => ({
updatePost: builder.mutation<Post, { id: number; title: string }>({
query: ({ id, title }) => ({
url: `/posts/${id}`,
method: 'PATCH',
body: { title },
}),
async onQueryStarted(
{ id, title },
{ dispatch, queryFulfilled }
) {
// Update both the detail and list cache optimistically
const patchResult1 = dispatch(
api.util.updateQueryData('getPost', id, (draft) => {
draft.title = title;
})
);

const patchResult2 = dispatch(
api.util.updateQueryData('getPosts', undefined, (draft) => {
const post = draft.find((p) => p.id === id);
if (post) post.title = title;
})
);

try {
// Wait for the mutation to succeed
await queryFulfilled;
// No need to do anything; the server response updates cache
} catch (error) {
// Rollback both patches if mutation fails
patchResult1.undo();
patchResult2.undo();
console.error('Update failed:', error);
}
},
invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }],
}),
}),
});

Key points:

  • updateQueryData() returns a patch object with an undo() method
  • Await queryFulfilled to know if the mutation succeeded
  • If queryFulfilled rejects, undo() restores the previous cache state
  • The invalidatesTags still fires after success, ensuring consistency

Handling Errors with onError and onSuccess

Hook into mutation lifecycle events for side effects:

const [createPost] = useCreatePostMutation({
fixedCacheKey: 'createPost', // Optional: share state across components
});

export function CreatePostForm() {
const [createPost, { isLoading }] = useCreatePostMutation();
const [errors, setErrors] = useState<Record<string, string>>({});

async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);

try {
const result = await createPost({
title: formData.get('title') as string,
content: formData.get('content') as string,
});

if ('error' in result) {
// Handle validation errors from the server
const errorData = (result.error as any).data;
if (errorData?.errors) {
setErrors(errorData.errors);
}
return;
}

// Success
e.currentTarget.reset();
setErrors({});
} catch (error) {
console.error('Network error:', error);
}
}

return (
<form onSubmit={handleSubmit}>
<div>
<input type="text" name="title" required />
{errors.title && <span className="error">{errors.title}</span>}
</div>
<div>
<textarea name="content" required />
{errors.content && <span className="error">{errors.content}</span>}
</div>
<button type="submit" disabled={isLoading}>
Create
</button>
</form>
);
}

Complex Mutations with Dependent Steps

Chain mutations using .unwrap() and async/await:

export function ImageUploadForm({ postId }: { postId: number }) {
const [updatePost] = useUpdatePostMutation();
const [uploadImage] = useUploadImageMutation();
const [isLoading, setIsLoading] = useState(false);

async function handleFileSelected(file: File) {
setIsLoading(true);

try {
// Step 1: Upload image, get URL
const { url } = await uploadImage({
postId,
file,
}).unwrap();

// Step 2: Update post with image URL
await updatePost({
id: postId,
imageUrl: url,
}).unwrap();

// Both succeeded; cache updated automatically
} catch (error) {
console.error('Failed:', error);
// RTK Query rolls back both mutations on error
} finally {
setIsLoading(false);
}
}

return (
<input
type="file"
accept="image/*"
disabled={isLoading}
onChange={(e) => e.target.files?.[0] && handleFileSelected(e.target.files[0])}
/>
);
}

Batch Mutations with queryFulfilled

Apply multiple cache updates atomically:

export const api = createApi({
// ...
endpoints: (builder) => ({
bulkDeletePosts: builder.mutation<void, number[]>({
query: (ids) => ({
url: '/posts/bulk-delete',
method: 'POST',
body: { ids },
}),
async onQueryStarted(ids, { dispatch, queryFulfilled }) {
// Optimistic: remove all IDs from cache
const patchResult = dispatch(
api.util.updateQueryData('getPosts', undefined, (draft) => {
return draft.filter((p) => !ids.includes(p.id));
})
);

try {
await queryFulfilled;
} catch {
patchResult.undo();
}
},
invalidatesTags: ['Post'],
}),
}),
});

Conditional Mutations with skipToken

Skip a mutation if conditions aren't met:

import { skipToken } from '@reduxjs/toolkit/query/react';

export function PublishPostButton({ postId }: { postId?: number }) {
const [publishPost] = usePublishPostMutation();

async function handlePublish() {
if (!postId) return; // Guard

try {
await publishPost(postId).unwrap();
console.log('Published');
} catch (error) {
console.error('Failed to publish:', error);
}
}

return (
<button onClick={handlePublish} disabled={!postId}>
Publish
</button>
);
}

Comparing Mutation Patterns: SWR vs RTK Query

AspectSWRRTK Query
DefinitionManual fetch()Declarative endpoint config
Hook signatureNone built-in[trigger, { isLoading, error, data }]
Optimistic updatesManual state mutationBuilt-in updateQueryData
RollbackManual mutate()Automatic via undo()
Error handlingManual try/catcherror in hook return
Side effectsUse useEffect after mutationBuilt-in lifecycle hooks
Type inferenceManual generic typingAutomatic from endpoint
Cache invalidationManual mutate() callsAutomatic via invalidatesTags

RTK Query's mutation endpoints provide guardrails preventing entire categories of bugs.

Key Takeaways

  • Define mutations in your API slice with builder.mutation(), specifying response and argument types
  • Auto-generated hooks handle loading, error, and data states automatically
  • Optimistic updates via onQueryStarted and updateQueryData make mutations instant and rollbackable
  • Error handling is built-in: check the error property or .unwrap() to throw on failure
  • Tags automatically invalidate related queries, keeping cache consistent

Frequently Asked Questions

Can I share mutation state across components?

Yes, use fixedCacheKey:

const [trigger] = useCreatePostMutation({
fixedCacheKey: 'createPost',
});

All components using the same fixedCacheKey share loading and error state.

What's the difference between calling trigger() and .unwrap()?

trigger() returns a promise that always resolves (never rejects). .unwrap() extracts the result or throws on error:

// Returns: { data: Post } | { error: Error }
const result = await createPost({ /* ... */ });

// Throws on error
const post = await createPost({ /* ... */ }).unwrap();

Can I cancel a mutation in-flight?

No built-in API. Use AbortController in your baseQuery:

const baseQuery = fetchBaseQuery({
baseUrl: '...',
});

const api = createApi({
baseQuery,
// Pass `signal` from AbortController to fetch
});

How do I handle optimistic updates for multiple endpoints?

Dispatch multiple updateQueryData calls in onQueryStarted:

async onQueryStarted({ id }, { dispatch, queryFulfilled }) {
dispatch(api.util.updateQueryData('getPost', id, (draft) => { /* ... */ }));
dispatch(api.util.updateQueryData('getPosts', undefined, (draft) => { /* ... */ }));
try {
await queryFulfilled;
} catch {
// Both roll back if mutation fails
}
}

Does RTK Query support file uploads?

Yes. Use FormData in your mutation:

uploadFile: builder.mutation<{ url: string }, File>({
query: (file) => {
const formData = new FormData();
formData.append('file', file);
return {
url: '/upload',
method: 'POST',
body: formData,
};
},
})

Further Reading