DEV Community

Munna Thakur
Munna Thakur

Posted on

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


⚡ 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
Enter fullscreen mode Exit fullscreen mode
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

const queryClient = new QueryClient()

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <YourApp />
    </QueryClientProvider>
  )
}
Enter fullscreen mode Exit fullscreen mode

🧠 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>
  )
}
Enter fullscreen mode Exit fullscreen mode

📦 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)
Enter fullscreen mode Exit fullscreen mode

🎁 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(...)
Enter fullscreen mode Exit fullscreen mode

🔄 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)
Enter fullscreen mode Exit fullscreen mode

Type:

mutationFn: (variables: TVariables) => Promise<TData>
Enter fullscreen mode Exit fullscreen mode

Rules:

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

2. mutationKey 🏷

Optional unique identifier.

mutationKey: ['createUser']
mutationKey: ['updateUser', userId]
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

🔥 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')
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

6. onSettled 🏁

Runs ALWAYS (success or error).

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

  // Cleanup
  setIsSubmitting(false)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

⚠️ 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)
Enter fullscreen mode Exit fullscreen mode

9. networkMode 🌐

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

10. gcTime 🗑

How long unused mutation stays in cache.

gcTime: 5 * 60 * 1000  // 5 minutes (default)
Enter fullscreen mode Exit fullscreen mode

11. meta 📝

Custom metadata.

meta: {
  errorMessage: 'Failed to create user',
  trackingId: 'user-creation'
}
Enter fullscreen mode Exit fullscreen mode

12. throwOnError 🚨

Throw errors to Error Boundaries.

throwOnError: true

// Or conditional
throwOnError: (error) => error.status >= 500
Enter fullscreen mode Exit fullscreen mode

🎁 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)
  }
)
Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

Returns a promise.

When to use:

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

State Properties

data

const { data } = useMutation(...)
// Response from successful mutation
Enter fullscreen mode Exit fullscreen mode

error

const { error } = useMutation(...)
// Error object if failed
Enter fullscreen mode Exit fullscreen mode

status

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

Status Booleans

isIdle

// Mutation not triggered yet
const { isIdle } = useMutation(...)
Enter fullscreen mode Exit fullscreen mode

isPending

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

isSuccess

{mutation.isSuccess && <SuccessMessage />}
Enter fullscreen mode Exit fullscreen mode

isError

{mutation.isError && <ErrorMessage error={mutation.error} />}
Enter fullscreen mode Exit fullscreen mode

Failure Info

failureCount

// Number of failed attempts
const { failureCount } = useMutation(...)
Enter fullscreen mode Exit fullscreen mode

failureReason

// Last error that caused failure
const { failureReason } = useMutation(...)
Enter fullscreen mode Exit fullscreen mode

Other Properties

variables

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

submittedAt

// Timestamp of last submission
const { submittedAt } = useMutation(...)
Enter fullscreen mode Exit fullscreen mode

context

// Value returned from onMutate
const { context } = useMutation(...)
Enter fullscreen mode Exit fullscreen mode

reset()

// Reset to idle state
mutation.reset()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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')
  }
}
Enter fullscreen mode Exit fullscreen mode

2. Parallel Mutations

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

3. Global Config

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

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)
        }
      }
    })
  }
})
Enter fullscreen mode Exit fullscreen mode

🚨 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')
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

Global Handling

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

Error Boundaries

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

🆚 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)  // ✅
}
Enter fullscreen mode Exit fullscreen mode

❌ 2. Retrying POST Blindly

// BAD - Can create duplicates
retry: 3

// GOOD
retry: false  // Or use smart retry logic
Enter fullscreen mode Exit fullscreen mode

❌ 3. Not Invalidating

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

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

❌ 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)  // ✅
}
Enter fullscreen mode Exit fullscreen mode

❌ 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>
Enter fullscreen mode Exit fullscreen mode

❌ 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')
}
Enter fullscreen mode Exit fullscreen mode

🏗 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
  }
}
Enter fullscreen mode Exit fullscreen mode

✅ 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)  // ✅
}
Enter fullscreen mode Exit fullscreen mode

✅ 3. Avoid Retry for Non-Idempotent APIs

// PUT is idempotent
retry: 2  // ✅

// POST is not
retry: false  // ✅
Enter fullscreen mode Exit fullscreen mode

✅ 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!')  // ✅
Enter fullscreen mode Exit fullscreen mode

✅ 5. Invalidate in onSettled

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

✅ 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 ✅
  }
})
Enter fullscreen mode Exit fullscreen mode

🔥 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

Top comments (0)