Mastering useMutation — The Complete Deep Dive Guide

If useQuery is for reading server state, then useMutation is for writing to it.

This guide covers everything you need to master useMutation from @tanstack/react-query.

📋 Table of Contents

  • Installation & Setup
  • The Big Picture
  • Basic Usage
  • Complete API Reference
  • All Options Explained
  • All Return Values Explained
  • Optimistic Updates
  • Advanced Patterns
  • Error Handling
  • useQuery vs useMutation
  • Common Mistakes
  • Production Best Practices

⚡ Quick Summary

TL;DR: useMutation handles:

  • ✅ POST/PUT/PATCH/DELETE operations
  • ✅ Optimistic UI updates with rollback
  • ✅ Automatic retry with configurable logic
  • ✅ Smart cache invalidation
  • ✅ Error boundary integration
  • ✅ Lifecycle hooks (onMutate, onSuccess, onError, onSettled)

Length: ~25 min read | Level: Beginner to Advanced

📦 Installation & Setup {#installation–setup}

npm install @tanstack/react-query
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

const queryClient = new QueryClient()

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <YourApp />
    </QueryClientProvider>
  )
}

🧠 The Big Picture {#the-big-picture}

Purpose:

  1. Server Writes – POST/PUT/PATCH/DELETE
  2. Optimistic Updates – Update UI before server confirms
  3. Retry Handling – Automatic retry with backoff
  4. Cache Invalidation – Keep cache synced
  5. Error Rollback – Revert on failure

Key Difference:

Unlike useQuery, mutations DON’T run automatically.

You trigger them manually via mutate() or mutateAsync().

🚀 Basic Usage {#basic-usage}

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

function CreateUser() {
  const mutation = useMutation({
    mutationFn: (newUser) => {
      return fetch('/api/users', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(newUser)
      }).then(res => res.json())
    }
  })

  const handleSubmit = (e) => {
    e.preventDefault()
    mutation.mutate({ 
      name: 'John Doe', 
      email: 'john@example.com' 
    })
  }

  if (mutation.isPending) return <div>Creating...</div>
  if (mutation.isError) return <div>Error: {mutation.error.message}</div>
  if (mutation.isSuccess) return <div>User created!</div>

  return (
    <form onSubmit={handleSubmit}>
      <button type="submit">Create User</button>
    </form>
  )
}

📦 Complete API Reference {#complete-api-reference}

🎯 All Options

const mutation = useMutation({
  mutationFn,           // Required - API call function
  mutationKey,          // Optional - Unique identifier  
  onMutate,             // Before mutation
  onSuccess,            // After success
  onError,              // After error
  onSettled,            // After completion (success/error)
  retry,                // Retry logic
  retryDelay,           // Delay between retries
  networkMode,          // Network behavior
  gcTime,               // Garbage collection time
  meta,                 // Custom metadata
  throwOnError,         // Error Boundary integration
  scope,                // Mutation scope
}, queryClient)

🎁 All Return Values

const {
  data,                 // Mutation result
  error,                // Error object
  isError,              // Error state
  isIdle,               // Not triggered
  isPending,            // In progress
  isPaused,             // Network paused
  isSuccess,            // Succeeded
  failureCount,         // Failed attempts
  failureReason,        // Last error
  mutate,               // Sync trigger
  mutateAsync,          // Async trigger
  reset,                // Reset state
  status,               // 'idle' | 'pending' | 'error' | 'success'
  submittedAt,          // Timestamp
  variables,            // Input variables
  context,              // onMutate return value
} = useMutation(...)

🔄 All Options Explained {#all-options-explained}

1. mutationFn (Required) 🔑

The function that performs the mutation.

// POST
mutationFn: (newUser) => 
  fetch('/api/users', {
    method: 'POST',
    body: JSON.stringify(newUser)
  }).then(r => r.json())

// PUT
mutationFn: ({ id, ...data }) =>
  fetch(`/api/users/${id}`, {
    method: 'PUT',
    body: JSON.stringify(data)
  }).then(r => r.json())

// DELETE  
mutationFn: (userId) =>
  fetch(`/api/users/${userId}`, { method: 'DELETE' })

// With Axios
mutationFn: (data) => axios.post('/api/users', data)

Type:

mutationFn: (variables: TVariables) => Promise<TData>

Rules:

  • Must return a Promise
  • Should throw on error
  • Can accept any variables

2. mutationKey 🏷

Optional unique identifier.

mutationKey: ['createUser']
mutationKey: ['updateUser', userId]

Use cases:

  • DevTools grouping
  • Global state access
  • Mutation deduplication

3. onMutate 🚀

Runs BEFORE the mutation.

onMutate: async (newUser) => {
  // 1. Cancel ongoing queries
  await queryClient.cancelQueries({ queryKey: ['users'] })

  // 2. Snapshot for rollback
  const previousUsers = queryClient.getQueryData(['users'])

  // 3. Optimistically update
  queryClient.setQueryData(['users'], old => [...old, newUser])

  // 4. Return context
  return { previousUsers }
}

🔥 Most powerful feature for optimistic updates!

4. onSuccess

Runs after successful mutation.

onSuccess: (data, variables, context) => {
  // Invalidate queries
  queryClient.invalidateQueries({ queryKey: ['users'] })

  // Show toast
  toast.success('User created!')

  // Navigate
  navigate('/users')
}

Parameters:

  • data – Response from mutationFn
  • variables – Input passed to mutate()
  • context – Value from onMutate

5. onError

Runs when mutation fails.

onError: (error, variables, context) => {
  // Rollback optimistic update
  if (context?.previousUsers) {
    queryClient.setQueryData(['users'], context.previousUsers)
  }

  // Show error
  toast.error(`Error: ${error.message}`)

  // Log error
  Sentry.captureException(error)
}

6. onSettled 🏁

Runs ALWAYS (success or error).

onSettled: (data, error, variables, context) => {
  // Refetch to ensure sync
  queryClient.invalidateQueries({ queryKey: ['users'] })

  // Cleanup
  setIsSubmitting(false)
}

Best practice: Put invalidation here instead of onSuccess.

7. retry 🔁

Configure retry behavior.

// No retry (default)
retry: false

// Retry 3 times
retry: 3

// Custom logic
retry: (failureCount, error) => {
  // Don't retry 4xx errors
  if (error.status >= 400 && error.status < 500) return false
  return failureCount < 3
}

⚠️ Default is false (unlike useQuery which defaults to 3)

8. retryDelay

Delay between retries.

// Fixed (1 second)
retryDelay: 1000

// Exponential backoff
retryDelay: (attemptIndex) => 
  Math.min(1000 * 2 ** attemptIndex, 30000)

9. networkMode 🌐

networkMode: 'online'        // Only when online (default)
networkMode: 'always'        // Even offline
networkMode: 'offlineFirst'  // Cache first

10. gcTime 🗑

How long unused mutation stays in cache.

gcTime: 5 * 60 * 1000  // 5 minutes (default)

11. meta 📝

Custom metadata.

meta: {
  errorMessage: 'Failed to create user',
  trackingId: 'user-creation'
}

12. throwOnError 🚨

Throw errors to Error Boundaries.

throwOnError: true

// Or conditional
throwOnError: (error) => error.status >= 500

🎁 All Return Values Explained {#all-return-values-explained}

Core Trigger Methods

mutate – Synchronous

// Fire and forget
mutation.mutate({ name: 'John' })

// With callbacks
mutation.mutate(
  { name: 'John' },
  {
    onSuccess: (data) => console.log(data),
    onError: (error) => console.log(error)
  }
)

Does NOT return a promise.

mutateAsync – Asynchronous

const handleSubmit = async () => {
  try {
    const data = await mutation.mutateAsync({ name: 'John' })
    navigate('/success')
  } catch (error) {
    console.log('Error:', error)
  }
}

Returns a promise.

When to use:

  • ✅ Form submissions
  • ✅ Async/await flow
  • ✅ Sequential mutations

State Properties

data

const { data } = useMutation(...)
// Response from successful mutation

error

const { error } = useMutation(...)
// Error object if failed

status

const { status } = useMutation(...)
// 'idle' | 'pending' | 'error' | 'success'

Status Booleans

isIdle

// Mutation not triggered yet
const { isIdle } = useMutation(...)

isPending

// Mutation in progress
<button disabled={mutation.isPending}>
  {mutation.isPending ? 'Creating...' : 'Create'}
</button>

isSuccess

{mutation.isSuccess && <SuccessMessage />}

isError

{mutation.isError && <ErrorMessage error={mutation.error} />}

Failure Info

failureCount

// Number of failed attempts
const { failureCount } = useMutation(...)

failureReason

// Last error that caused failure
const { failureReason } = useMutation(...)

Other Properties

variables

// Input passed to last mutation
const { variables } = useMutation(...)
console.log('Creating:', variables)

submittedAt

// Timestamp of last submission
const { submittedAt } = useMutation(...)

context

// Value returned from onMutate
const { context } = useMutation(...)

reset()

// Reset to idle state
mutation.reset()

Use cases:

  • Clear success/error
  • Reset form
  • Allow re-submission

🔥 Optimistic Updates – Full Lifecycle {#optimistic-updates-full-lifecycle}

What are Optimistic Updates?

Update UI immediately before server confirms. If it fails, roll back.

Complete Flow:

User clicks → onMutate → mutationFn → onSuccess/onError → onSettled

Complete Example:

function TodoList() {
  const queryClient = useQueryClient()

  const addTodo = useMutation({
    mutationFn: (newTodo) => axios.post('/api/todos', newTodo),

    // 1. Before mutation
    onMutate: async (newTodo) => {
      // Cancel to prevent race conditions
      await queryClient.cancelQueries({ queryKey: ['todos'] })

      // Snapshot for rollback
      const previousTodos = queryClient.getQueryData(['todos'])

      // Optimistically update
      queryClient.setQueryData(['todos'], old => [
        ...old,
        { ...newTodo, id: Date.now(), optimistic: true }
      ])

      return { previousTodos }
    },

    // 2. On success
    onSuccess: (data) => {
      queryClient.setQueryData(['todos'], old =>
        old.map(todo => todo.optimistic ? data : todo)
      )
      toast.success('Todo added!')
    },

    // 3. On error - Rollback
    onError: (error, variables, context) => {
      queryClient.setQueryData(['todos'], context.previousTodos)
      toast.error('Failed to add todo')
    },

    // 4. Always
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] })
    }
  })

  return (
    <button onClick={() => addTodo.mutate({ title: 'New Todo' })}>
      Add Todo
    </button>
  )
}

When to Use:

Good for:

  • Adding items to lists
  • Toggling booleans (like/unlike)
  • Incrementing counters

Avoid for:

  • Complex calculations
  • File uploads
  • Payment processing

🎯 Advanced Patterns {#advanced-patterns}

1. Sequential Mutations

const createUser = useMutation({ mutationFn: createUserAPI })
const sendEmail = useMutation({ mutationFn: sendEmailAPI })

const handleSignup = async (data) => {
  try {
    const user = await createUser.mutateAsync(data)
    await sendEmail.mutateAsync({ email: user.email })
    toast.success('Signup complete!')
  } catch (error) {
    toast.error('Signup failed')
  }
}

2. Parallel Mutations

const [profile, avatar] = await Promise.all([
  updateProfile.mutateAsync(profileData),
  uploadAvatar.mutateAsync(avatarFile)
])

3. Global Config

const queryClient = new QueryClient({
  defaultOptions: {
    mutations: {
      retry: 1,
      onError: (error) => console.error(error)
    }
  }
})

4. Undo/Redo

const deleteTodo = useMutation({
  mutationFn: deleteTodoAPI,
  onMutate: async (todoId) => {
    const previous = queryClient.getQueryData(['todos'])
    queryClient.setQueryData(['todos'], old =>
      old.filter(t => t.id !== todoId)
    )
    return { previous, todoId }
  },
  onSuccess: (data, todoId, context) => {
    toast.success('Deleted', {
      action: {
        label: 'Undo',
        onClick: () => {
          queryClient.setQueryData(['todos'], context.previous)
        }
      }
    })
  }
})

🚨 Error Handling {#error-handling}

Component-Level

const mutation = useMutation({
  mutationFn: createUser,
  onError: (error) => {
    if (error.response?.status === 400) {
      toast.error('Invalid input')
    } else if (error.response?.status === 401) {
      navigate('/login')
    } else {
      toast.error('Something went wrong')
    }
  }
})

Global Handling

const queryClient = new QueryClient({
  defaultOptions: {
    mutations: {
      onError: (error) => {
        Sentry.captureException(error)
        if (!error.handled) {
          toast.error('An error occurred')
        }
      }
    }
  }
})

Error Boundaries

const mutation = useMutation({
  mutationFn: createUser,
  throwOnError: true  // Throws to Error Boundary
})

🆚 useQuery vs useMutation {#usequery-vs-usemutation}

Feature useQuery useMutation
Auto run ✅ Yes ❌ No
Caching ✅ Yes ❌ No
Optimistic updates ❌ No ✅ Yes
Trigger Automatic Manual
Use case GET POST/PUT/DELETE
Default retry 3 0

⚠️ Common Mistakes {#common-mistakes}

❌ 1. Forgetting Rollback

// BAD
onMutate: (data) => {
  queryClient.setQueryData(['users'], data)
  // Missing: return context
}

// GOOD
onMutate: async (data) => {
  const previous = queryClient.getQueryData(['users'])
  queryClient.setQueryData(['users'], data)
  return { previous }  // ✅
},
onError: (err, data, context) => {
  queryClient.setQueryData(['users'], context.previous)  // ✅
}

❌ 2. Retrying POST Blindly

// BAD - Can create duplicates
retry: 3

// GOOD
retry: false  // Or use smart retry logic

❌ 3. Not Invalidating

// BAD
onSuccess: () => {
  toast.success('Created!')
  // Missing: invalidation
}

// GOOD
onSuccess: () => {
  queryClient.invalidateQueries({ queryKey: ['users'] })  // ✅
  toast.success('Created!')
}

❌ 4. Overusing Optimistic Updates

// BAD - Complex calculation should wait
onMutate: (input) => {
  const result = complexCalculation(input)  // ⚠️
  queryClient.setQueryData(['data'], result)
}

// GOOD - Wait for server
onSuccess: (result) => {
  queryClient.setQueryData(['data'], result)  // ✅
}

❌ 5. Not Handling Loading

// BAD
<button onClick={() => mutation.mutate(data)}>Submit</button>

// GOOD  
<button 
  onClick={() => mutation.mutate(data)}
  disabled={mutation.isPending}  // ✅
>
  {mutation.isPending ? 'Submitting...' : 'Submit'}
</button>

❌ 6. Using Wrong Trigger

// BAD
const handleSubmit = async () => {
  mutation.mutate(data)
  navigate('/success')  // ⚠️ Navigates before completion
}

// GOOD
const handleSubmit = async () => {
  await mutation.mutateAsync(data)  // ✅
  navigate('/success')
}

🏗 Production Best Practices {#production-best-practices}

✅ 1. Use mutateAsync for Forms

const handleSubmit = async (data) => {
  try {
    await mutation.mutateAsync(data)
    resetForm()
    navigate('/success')
  } catch (error) {
    // Already handled by onError
  }
}

✅ 2. Always Handle Rollback

onMutate: async (data) => {
  await queryClient.cancelQueries({ queryKey: ['data'] })
  const previous = queryClient.getQueryData(['data'])
  queryClient.setQueryData(['data'], data)
  return { previous }  // ✅
},
onError: (err, vars, context) => {
  queryClient.setQueryData(['data'], context.previous)  // ✅
}

✅ 3. Avoid Retry for Non-Idempotent APIs

// PUT is idempotent
retry: 2  // ✅

// POST is not
retry: false  // ✅

✅ 4. Keep mutationFn Pure

// BAD
mutationFn: async (data) => {
  const result = await api.post(data)
  toast.success('Done!')  // ⚠️ Side effect
  return result
}

// GOOD
mutationFn: (data) => api.post(data),  // ✅
onSuccess: () => toast.success('Done!')  // ✅

✅ 5. Invalidate in onSettled

onSettled: () => {
  // ✅ Runs on both success and error
  queryClient.invalidateQueries({ queryKey: ['users'] })
}

✅ 6. TypeScript Support

interface User { id: number; name: string }
interface CreateUserInput { name: string }

const createUser = useMutation<User, Error, CreateUserInput>({
  mutationFn: (input) => api.createUser(input),
  onSuccess: (data) => {
    // data is typed as User ✅
  }
})

🔥 Key Takeaways

useMutation is a:

  1. Transaction Manager – Coordinates writes
  2. Optimistic UI Engine – Updates before confirmation
  3. Retry Controller – Handles failures
  4. Rollback Mechanism – Reverts on errors
  5. Server Sync Bridge – Keeps cache in sync

📌 Final Thought

Understanding useMutation is mandatory for scalable React apps.

The combination of:

  • Optimistic updates
  • Smart retry
  • Cache invalidation
  • Error rollback

Makes it the most powerful tool for server writes.

🚀 Next Steps

  1. Read TanStack Query docs
  2. Master useQuery for reads
  3. Learn query invalidation strategies
  4. Practice optimistic update patterns
  5. Setup React Query DevTools

💡 Pro Tips

Master these first:

  • Basic mutation with mutate()
  • Async flow with mutateAsync()
  • Cache invalidation in onSuccess
  • Error handling in onError

Then move to optimistic updates!

🤝 Let’s Connect

Found this helpful?

  • ❤️ Save for reference
  • 🔄 Share with your team
  • 💬 Ask questions in comments
  • 🐦 Follow for more React content

What’s your biggest mutation challenge? Comment below! 👇

📚 Official Docs:

https://tanstack.com/query/latest/docs/react/overview

Happy mutating! 🎯

Tags: #react #tanstack #reactquery #javascript #webdev #frontend #mutations

Leave a Reply