The Complete Guide to React Query's useMutation: Everything You Need to Know

Are you struggling with form submissions in React Query? Finding yourself confused about how to handle API responses, or scratching your head over when to use useMutation versus useQuery? You're not alone. Many developers face these exact challenges when working with React Query's mutation system.

This comprehensive guide will walk you through everything you need to know about useMutation, from basic form submissions to advanced patterns and best practices. By the end, you'll have a solid understanding of how to effectively manage your application's data mutations.

What is useMutation?

At its core, useMutation is a React Query hook designed for handling server-side effects - primarily creating, updating, or deleting data. Unlike useQuery which is meant for data fetching, useMutation is your go-to tool when you need to make changes to your server state.

Here's a simple example to illustrate:

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

function CreateTodoForm() {
  const mutation = useMutation({
    mutationFn: (newTodo) => {
      return axios.post('/api/todos', newTodo)
    },
  })

  return (
    <form onSubmit={(e) => {
      e.preventDefault()
      mutation.mutate({ title: 'Do laundry' })
    }}>
      <button type="submit">Create Todo</button>
    </form>
  )
}

Why Use useMutation?

You might be wondering why you should use useMutation instead of a simple API call with fetch or axios. Here are the key benefits:

  1. Automatic Loading States: No more manual tracking of loading states with useState

  2. Built-in Error Handling: Comprehensive error state management out of the box

  3. Retry Logic: Automatic retries for failed mutations

  4. Optimistic Updates: Update your UI instantly while waiting for the server response

  5. Cache Integration: Seamless integration with React Query's powerful caching system

Understanding Mutation States

One of the most powerful aspects of useMutation is its built-in state management. Every mutation goes through different states that you can track:

const mutation = useMutation({
  mutationFn: createTodo,
})

console.log(mutation.isIdle)      // True if the mutation hasn't been called yet
console.log(mutation.isLoading)   // True while the mutation is in progress
console.log(mutation.isError)     // True if the mutation encountered an error
console.log(mutation.isSuccess)   // True if the mutation was successful

Basic Usage: Form Submissions

Let's tackle one of the most common use cases: form submissions. Many developers struggle with handling form data and responses effectively. Here's a complete example:

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

interface TodoFormData {
  title: string;
  description: string;
}

function TodoForm() {
  const queryClient = useQueryClient();
  
  const mutation = useMutation({
    mutationFn: (formData: TodoFormData) => {
      return axios.post('/api/todos', formData);
    },
    onSuccess: (data) => {
      // Invalidate and refetch todos list
      queryClient.invalidateQueries({ queryKey: ['todos'] });
      console.log('New todo created:', data);
    },
    onError: (error) => {
      console.error('Failed to create todo:', error);
    }
  });

  const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const formData = new FormData(event.currentTarget);
    
    mutation.mutate({
      title: formData.get('title') as string,
      description: formData.get('description') as string,
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="title">Title:</label>
        <input type="text" id="title" name="title" required />
      </div>
      
      <div>
        <label htmlFor="description">Description:</label>
        <textarea id="description" name="description" required />
      </div>

      <button 
        type="submit" 
        disabled={mutation.isLoading}
      >
        {mutation.isLoading ? 'Creating...' : 'Create Todo'}
      </button>

      {mutation.isError && (
        <div style={{ color: 'red' }}>
          Error: {mutation.error.message}
        </div>
      )}

      {mutation.isSuccess && (
        <div style={{ color: 'green' }}>
          Todo created successfully!
        </div>
      )}
    </form>
  );
}

This example demonstrates several key concepts:

  1. Type Safety: Using TypeScript interfaces for form data

  2. Form Handling: Proper form submission with FormData API

  3. Loading States: Disabling the submit button during submission

  4. Error Handling: Displaying error messages when something goes wrong

  5. Success Feedback: Showing success messages to users

  6. Cache Management: Invalidating related queries after successful mutation

Error Handling Deep Dive

Error handling is crucial for providing a good user experience. React Query's useMutation provides several ways to handle errors:

// Global error handler
const queryClient = new QueryClient({
  mutationCache: new MutationCache({
    onError: (error, variables, context, mutation) => {
      // Global error handling
      console.error(`Something went wrong: ${error.message}`);
    },
  }),
});

// Per-mutation error handling
const mutation = useMutation({
  mutationFn: createTodo,
  onError: (error, variables, context) => {
    // Handle specific mutation errors
    if (error.response?.status === 400) {
      console.error('Validation error:', error.response.data);
    } else if (error.response?.status === 401) {
      // Handle unauthorized access
      navigate('/login');
    }
  },
});

// Try-catch block for immediate handling
try {
  await mutation.mutateAsync(newTodo);
} catch (error) {
  // Handle error immediately
  console.error('Failed to create todo:', error);
}

Advanced Patterns

1. Optimistic Updates

Optimistic updates improve perceived performance by updating the UI before the server confirms the change:

const queryClient = useQueryClient();

const mutation = useMutation({
  mutationFn: updateTodo,
  onMutate: async (newTodo) => {
    // Cancel any outgoing refetches
    await queryClient.cancelQueries({ queryKey: ['todos'] });

    // Snapshot the previous value
    const previousTodos = queryClient.getQueryData(['todos']);

    // Optimistically update to the new value
    queryClient.setQueryData(['todos'], (old) => {
      return old.map((todo) => 
        todo.id === newTodo.id ? newTodo : todo
      );
    });

    // Return context with the previous value
    return { previousTodos };
  },
  onError: (err, newTodo, context) => {
    // If the mutation fails, use the context we returned above
    queryClient.setQueryData(['todos'], context.previousTodos);
  },
  onSettled: () => {
    // Always refetch after error or success
    queryClient.invalidateQueries({ queryKey: ['todos'] });
  },
});

2. Mutation Queue

React Query automatically queues mutations, ensuring they execute in order:

// These will execute in order
mutation.mutate(todo1);
mutation.mutate(todo2);
mutation.mutate(todo3);

3. Side Effects and Callbacks

React Query provides several callback opportunities for handling side effects:

const mutation = useMutation({
  mutationFn: createTodo,
  onMutate: (variables) => {
    // Called before the mutation function
    console.log('About to create:', variables);
  },
  onSuccess: (data, variables) => {
    // Called on successful mutation
    toast.success('Todo created successfully!');
  },
  onError: (error, variables, context) => {
    // Called on mutation error
    toast.error('Failed to create todo');
  },
  onSettled: (data, error, variables, context) => {
    // Called after mutation is either successful or has failed
    console.log('Mutation settled');
  },
});

Best Practices and Common Pitfalls

1. Handling Response Data

One common confusion is how to access mutation response data. Here's the right way:

// ❌ Don't do this
const [responseData, setResponseData] = useState(null);
mutation.mutate(data, {
  onSuccess: (response) => setResponseData(response),
});

// ✅ Do this instead
const { data, isSuccess } = mutation;
// Access data directly from mutation object

2. Proper Type Definitions

Using TypeScript? Here's how to properly type your mutations:

interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

interface CreateTodoDTO {
  title: string;
}

const mutation = useMutation<Todo, Error, CreateTodoDTO>({
  mutationFn: (newTodo) => {
    return axios.post<Todo>('/api/todos', newTodo);
  },
});

3. Reset Mutation State

Remember to reset mutation state when needed:

function TodoForm() {
  const mutation = useMutation({
    mutationFn: createTodo,
  });

  // Reset mutation state when component unmounts or when needed
  useEffect(() => {
    return () => {
      mutation.reset();
    };
  }, []);

  return (
    // ... form JSX
  );
}

Advanced Use Cases

1. Batch Mutations

Need to perform multiple mutations at once? Here's how:

function BatchTodoCreator() {
  const mutation = useMutation({
    mutationFn: async (todos) => {
      const promises = todos.map(todo => 
        axios.post('/api/todos', todo)
      );
      return Promise.all(promises);
    },
  });

  const createMultipleTodos = () => {
    const todos = [
      { title: 'Todo 1' },
      { title: 'Todo 2' },
      { title: 'Todo 3' },
    ];
    mutation.mutate(todos);
  };

  return (
    <button 
      onClick={createMultipleTodos}
      disabled={mutation.isLoading}
    >
      Create Multiple Todos
    </button>
  );
}

2. Dependent Mutations

Sometimes you need to chain mutations based on previous results:

function CreateProjectWithTasks() {
  const createProject = useMutation({
    mutationFn: (project) => axios.post('/api/projects', project),
  });

  const createTask = useMutation({
    mutationFn: (task) => axios.post('/api/tasks', task),
  });

  const handleCreate = async () => {
    try {
      // Create project first
      const project = await createProject.mutateAsync({
        name: 'New Project',
      });

      // Then create associated tasks
      await createTask.mutateAsync({
        projectId: project.id,
        title: 'First Task',
      });
    } catch (error) {
      console.error('Failed to create project with tasks:', error);
    }
  };

  return (
    <button 
      onClick={handleCreate}
      disabled={createProject.isLoading || createTask.isLoading}
    >
      Create Project with Tasks
    </button>
  );
}

3. Retry Configuration

Customize retry behavior for failed mutations:

const mutation = useMutation({
  mutationFn: createTodo,
  retry: 3, // Number of retry attempts
  retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
});

Integration with React Query Cache

1. Updating Query Cache After Mutation

One of the most powerful features of React Query is its cache management. Here's how to update the cache after a mutation:

function TodoList() {
  const queryClient = useQueryClient();
  
  const mutation = useMutation({
    mutationFn: createTodo,
    onSuccess: (newTodo) => {
      // Update the todos list in cache
      queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);
      
      // Or invalidate the query to trigger a refetch
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });

  const { data: todos } = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  });

  return (
    <div>
      {/* Todo list rendering */}
    </div>
  );
}

2. Optimistic Updates with Rollback

Here's a more complex example of optimistic updates with proper error handling:

const mutation = useMutation({
  mutationFn: updateTodo,
  onMutate: async (newTodo) => {
    // Cancel any outgoing refetches
    await queryClient.cancelQueries({ queryKey: ['todos', newTodo.id] });

    // Snapshot the previous value
    const previousTodo = queryClient.getQueryData(['todos', newTodo.id]);

    // Optimistically update to the new value
    queryClient.setQueryData(['todos', newTodo.id], newTodo);

    // Return a context object with the snapshotted value
    return { previousTodo };
  },
  onError: (err, newTodo, context) => {
    queryClient.setQueryData(
      ['todos', newTodo.id],
      context.previousTodo
    );
  },
  onSettled: (newTodo) => {
    queryClient.invalidateQueries({
      queryKey: ['todos', newTodo.id],
    });
  },
});

Performance Optimization

1. Debouncing Mutations

When dealing with rapid mutations, implement debouncing:

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

function SearchForm() {
  const mutation = useMutation({
    mutationFn: searchAPI,
  });

  const debouncedMutate = React.useMemo(
    () => debounce(mutation.mutate, 500),
    [mutation.mutate]
  );

  return (
    <input
      type="text"
      onChange={(e) => debouncedMutate(e.target.value)}
    />
  );
}

2. Preventing Double Submissions

Protect against accidental double submissions:

function SubmitForm() {
  const mutation = useMutation({
    mutationFn: submitData,
  });

  const handleSubmit = async (e) => {
    e.preventDefault();
    
    if (mutation.isLoading) return; // Prevent double submission
    
    const formData = new FormData(e.target);
    mutation.mutate(Object.fromEntries(formData));
  };

  return (
    <form onSubmit={handleSubmit}>
      <button 
        type="submit" 
        disabled={mutation.isLoading}
      >
        {mutation.isLoading ? 'Submitting...' : 'Submit'}
      </button>
    </form>
  );
}

Common Gotchas and Solutions

1. Handling Stale Closures

Be careful with stale closures in mutation callbacks:

// ❌ Don't do this
const [count, setCount] = useState(0);

const mutation = useMutation({
  mutationFn: createTodo,
  onSuccess: () => {
    // This will use a stale count value
    setCount(count + 1);
  },
});

// ✅ Do this instead
const mutation = useMutation({
  mutationFn: createTodo,
  onSuccess: () => {
    setCount(prev => prev + 1);
  },
});

2. Proper Error Boundaries

Implement error boundaries for mutation errors:

class MutationErrorBoundary extends React.Component {
  state = { hasError: false };

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  render() {
    if (this.state.hasError) {
      return <h1>Something went wrong with the mutation.</h1>;
    }

    return this.props.children;
  }
}

Testing Mutations

Testing your mutations is crucial for maintaining a reliable application. Here's how to test different scenarios:

import { renderHook } from '@testing-library/react-hooks';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

describe('Todo Mutation', () => {
  let queryClient;

  beforeEach(() => {
    queryClient = new QueryClient();
  });

  it('should create a todo successfully', async () => {
    const wrapper = ({ children }) => (
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    );

    const { result, waitFor } = renderHook(
      () => useMutation({ mutationFn: createTodo }),
      { wrapper }
    );

    result.current.mutate({ title: 'Test Todo' });

    await waitFor(() => result.current.isSuccess);

    expect(result.current.data).toBeDefined();
  });

  it('should handle errors appropriately', async () => {
    // Test implementation
  });
});

Conclusion

React Query's useMutation hook is a powerful tool for managing server state mutations in your React applications. By following the patterns and best practices outlined in this guide, you can:

  • Handle form submissions effectively

  • Manage loading and error states

  • Implement optimistic updates

  • Maintain cache consistency

  • Write testable mutation logic

Remember these key takeaways:

  1. Always handle loading and error states appropriately

  2. Use TypeScript for better type safety

  3. Implement proper error boundaries

  4. Take advantage of React Query's built-in cache management

  5. Test your mutations thoroughly

Additional Resources

By following these guidelines and best practices, you'll be well-equipped to handle any data mutation scenarios in your React applications using React Query's useMutation hook.

Raymond Yeh

Raymond Yeh

Published on 10 March 2025

Get engineers' time back from marketing!

Don't let managing a blog on your site get in the way of your core product.

Wisp empowers your marketing team to create and manage content on your website without consuming more engineering hours.

Get started in few lines of codes.

Choosing a CMS
Related Posts
Managing Query Keys for Cache Invalidation in React Query

Managing Query Keys for Cache Invalidation in React Query

Learn effective React Query cache invalidation strategies using Query Key Factory, custom hooks, and centralized services. Master query key management for scalable React applications.

Read Full Story
SSR with Remix using TanStack Query

SSR with Remix using TanStack Query

Explore SSR with Remix and TanStack Query in this article, focusing on setup, best practices, and real-world examples to boost SEO, enhance load times, and ensure smoother user interactions.

Read Full Story
Why Senior Developers Choose Tanstack Query Over Fetch and useEffect

Why Senior Developers Choose Tanstack Query Over Fetch and useEffect

Why rely on fetch and useEffect for data fetching? Discover why senior developers prefer Tanstack Query for better performance and maintainability.

Read Full Story
Loading...