Skip to main content

useMutation in React: Update Server State and Sync UI

useMutation is the counterpart to useQuery: it executes write operations (POST, PATCH, DELETE) and synchronizes the cache afterward. Unlike queries, mutations do not cache their results; each call runs the operation fresh. But mutations are paired with query invalidation or optimistic updates to keep the cache and UI in sync. This article covers the mutation lifecycle, refetching dependent queries, optimistic updates that feel instant, and error handling patterns that keep your app's data model correct.

I once shipped a form that submitted successfully (data saved on the server) but the UI still showed stale data because I forgot to invalidate the related query. The user had to manually refresh the page to see their changes. Learning to pair mutations with cache invalidation eliminated that frustration: now updates appear instantly and always reflect the server truth.

What Is useMutation and When to Use It

useMutation executes a function (typically an async API call) and tracks its loading, error, and success states. Unlike useQuery, mutations do not cache results and do not refetch automatically; they execute on explicit invocation via mutate() or mutateAsync().

import { useMutation } from '@tanstack/react-query';

function CreatePostForm() {
const { mutate, isPending, error, isError, status } = useMutation({
mutationFn: async (newPost) => {
const res = await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(newPost),
});
if (!res.ok) throw new Error('Failed to create post');
return res.json();
},
onSuccess: (data) => {
console.log('Post created:', data);
},
onError: (error) => {
console.error('Error:', error.message);
},
});

const handleSubmit = (e) => {
e.preventDefault();
mutate({ title: 'New Post', body: 'Content' });
};

return (
<form onSubmit={handleSubmit}>
<input type="text" placeholder="Title" />
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Post'}
</button>
{isError && <p style={{ color: 'red' }}>{error.message}</p>}
</form>
);
}

The mutation lifecycle: user submits form → mutate() is called → isPending: true → fetch runs → on success/error, onSuccess or onError callback runs → isPending: false.

Invalidating Queries After Mutations

After a mutation succeeds, refetch related queries to keep the UI in sync:

function useAddPost() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: async (newPost) => {
const res = await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(newPost),
});
if (!res.ok) throw new Error('Failed to create post');
return res.json();
},
onSuccess: () => {
// Invalidate the posts query so it refetches
queryClient.invalidateQueries({
queryKey: ['posts'],
exact: false, // Match any posts query (filters, pagination, etc.)
});
},
});
}

function PostsList() {
const { data: posts } = useQuery({
queryKey: ['posts'],
queryFn: async () => {
const res = await fetch('/api/posts');
return res.json();
},
});

const { mutate: addPost } = useAddPost();

return (
<div>
{posts?.map(post => <PostCard key={post.id} post={post} />)}
<button onClick={() => addPost({ title: 'New' })}>Add Post</button>
</div>
);
}

When the user clicks "Add Post", the mutation runs, and on success, all posts queries are invalidated. The PostsList component automatically refetches the latest posts without manual state management.

Optimistic Updates

Optimistic updates assume the mutation will succeed and immediately update the cache with the new data. If the mutation fails, the old data is restored. This makes the UI feel instant:

function useAddPost() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: async (newPost) => {
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 1000));
const res = await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(newPost),
});
if (!res.ok) throw new Error('Failed to create post');
return res.json();
},
onMutate: async (newPost) => {
// Cancel any ongoing refetch of posts to avoid race conditions
await queryClient.cancelQueries({ queryKey: ['posts'] });

// Save the old data for rollback
const previousPosts = queryClient.getQueryData(['posts']);

// Optimistically update the cache
queryClient.setQueryData(['posts'], (old) => [
...(old || []),
{ id: Math.random(), ...newPost },
]);

// Return context for rollback on error
return { previousPosts };
},
onError: (err, newPost, context) => {
// Restore old data if mutation failed
if (context?.previousPosts) {
queryClient.setQueryData(['posts'], context.previousPosts);
}
},
onSuccess: () => {
// Refetch to get the server-assigned ID and ensure consistency
queryClient.invalidateQueries({
queryKey: ['posts'],
exact: false,
});
},
});
}

The lifecycle:

  1. User submits form
  2. onMutate runs immediately: cancel refetch, save old data, update cache with optimistic data
  3. UI re-renders with new post (instant feedback)
  4. Network request happens in background
  5. On success, onSuccess refetches to sync with server (the temporary client-side ID is replaced with the server ID)
  6. On error, onError restores old data

Mutation States and Cleanup

Use mutation states for fine-grained UI feedback:

function CreatePostForm() {
const {
mutate,
status, // 'idle' | 'pending' | 'success' | 'error'
isPending, // status === 'pending'
isSuccess, // status === 'success'
isError, // status === 'error'
error,
reset, // Reset to idle state
} = useMutation({
mutationFn: (newPost) => fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(newPost),
}).then(r => r.json()),
});

const handleSubmit = (e) => {
e.preventDefault();
mutate({ title: 'New Post' });
};

return (
<form onSubmit={handleSubmit}>
<input type="text" placeholder="Title" disabled={isPending} />
<button disabled={isPending}>
{isPending ? 'Creating...' : 'Create'}
</button>

{isSuccess && <p style={{ color: 'green' }}>Post created!</p>}
{isError && <p style={{ color: 'red' }}>{error.message}</p>}

{(isSuccess || isError) && (
<button onClick={() => reset()}>Clear Message</button>
)}
</form>
);
}

Call reset() to return the mutation to idle state, clearing success/error messages.

Multiple Mutations on One Update

Coordinate multiple mutations (e.g., create a post and increment user's post count):

function useCreatePostWithStats() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: async (newPost) => {
// Create post
const postRes = await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(newPost),
});
if (!postRes.ok) throw new Error('Failed to create post');
const post = await postRes.json();

// Increment user stats
const statsRes = await fetch(`/api/users/${newPost.userId}/stats`, {
method: 'PATCH',
body: JSON.stringify({ postCount: '+1' }),
});
if (!statsRes.ok) throw new Error('Failed to update stats');

return post;
},
onSuccess: (post) => {
// Invalidate both posts and user stats
queryClient.invalidateQueries({
queryKey: ['posts'],
exact: false,
});
queryClient.invalidateQueries({
queryKey: ['userStats'],
exact: false,
});
},
});
}

If either mutation fails, the entire operation is rolled back by the error handling.

Server-Driven Mutation Responses

If your server returns updated data in the mutation response, use it directly:

function useUpdatePost() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: async ({ id, updates }) => {
const res = await fetch(`/api/posts/${id}`, {
method: 'PATCH',
body: JSON.stringify(updates),
});
if (!res.ok) throw new Error('Failed to update post');
return res.json(); // Server returns the updated post
},
onSuccess: (updatedPost) => {
// Update the cache with the server response
queryClient.setQueryData(['post', updatedPost.id], updatedPost);

// Also update in the posts list if it exists
queryClient.setQueryData(['posts'], (old) =>
old?.map(p => p.id === updatedPost.id ? updatedPost : p)
);
},
});
}

Using the server response directly ensures consistency without a refetch.

Error Recovery and Retry

Handle mutation errors with user-friendly messaging and retry logic:

function useDeletePost() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: async (postId) => {
const res = await fetch(`/api/posts/${postId}`, {
method: 'DELETE',
});
if (res.status === 404) throw new Error('Post not found');
if (res.status === 403) throw new Error('You do not have permission to delete this post');
if (!res.ok) throw new Error('Failed to delete post');
return { postId };
},
retry: 1, // Retry once on transient errors
onSuccess: ({ postId }) => {
queryClient.invalidateQueries({
queryKey: ['posts'],
exact: false,
});
},
});
}

function PostCard({ post }) {
const { mutate: deletePost, isPending, error, isError } = useDeletePost();

return (
<div>
<h3>{post.title}</h3>
<button onClick={() => deletePost(post.id)} disabled={isPending}>
{isPending ? 'Deleting...' : 'Delete'}
</button>
{isError && <p style={{ color: 'red' }}>{error.message}</p>}
</div>
);
}

Mutation Side Effects Lifecycle

All callbacks in useMutation:

CallbackWhenUse For
onMutateBefore API callOptimistic updates, cancel ongoing refetches
onSuccessAfter successful API callCache updates, toast notifications, navigation
onErrorAfter failed API callRollback optimistic updates, error toasts
onSettledAfter success or errorCleanup (close modals, reset forms)
const { mutate } = useMutation({
mutationFn: updatePost,
onMutate: () => console.log('Starting mutation'),
onSuccess: () => console.log('Success'),
onError: () => console.log('Error'),
onSettled: () => console.log('Mutation complete'),
});

Key Takeaways

  • useMutation executes write operations and tracks isPending, error, and success states.
  • Pair mutations with queryClient.invalidateQueries() to refetch and sync dependent queries.
  • Use onMutate for optimistic updates; onError to rollback on failure; onSuccess to refetch and confirm.
  • Server-driven mutations (where the server response contains updated data) are faster than refetching.
  • Use mutation status states for fine-grained UI feedback (pending, success, error).
  • retry and retryDelay options retry failed mutations automatically before showing errors.

Frequently Asked Questions

Should I always refetch after a mutation, or can I update the cache directly?

Both approaches work. Refetching (invalidateQueries) is simpler and ensures server truth. Direct updates (setQueryData) are faster if you have the updated data from the mutation response. For production apps, prefer refetching unless performance is critical; it is safer and less error-prone.

Can I use useMutation for GET requests?

Technically yes, but it is not idiomatic. useQuery is for reading data (with caching and auto-refetch); useMutation is for writing data. If you need to fetch data on demand without caching, use useMutation or the lower-level queryClient.fetchQuery().

What is the difference between mutate() and mutateAsync()?

mutate() accepts callbacks (onSuccess, onError, onSettled) and does not return a promise. mutateAsync() returns a promise and is useful if you want to use await:

try {
const data = await mutateAsync(newPost);
console.log('Created:', data);
} catch (error) {
console.error('Failed:', error);
}

Can I track multiple mutations at once?

Yes, each call to mutate() or mutateAsync() increments an internal counter. You can check variables to see what data is being mutated:

const { mutate, variables, isPending } = useMutation({...});

if (isPending) console.log('Mutating:', variables);

What happens if a mutation succeeds on the server but fails locally (e.g., network error after the server responds)?

This is a race condition. The mutation likely succeeded on the server but the response did not reach the client. Use exponential backoff and onError callbacks to handle this gracefully. Consider server-driven reconciliation: periodically refetch affected queries to sync.

Further Reading