Skip to content

useMutation

The useMutation composable handles data modifications like creating, updating, or deleting resources. Unlike queries, mutations are not cached and must be triggered manually.

Basic Usage

vue
<template>
  <form @submit.prevent="handleSubmit">
    <input v-model="name" placeholder="User name" />
    <input v-model="email" placeholder="Email" />
    
    <button 
      type="submit" 
      :disabled="createUser.executing"
    >
      {{ createUser.executing ? 'Creating...' : 'Create User' }}
    </button>
    
    <div v-if="createUser.error" class="error">
      Error: {{ createUser.error.message }}
    </div>
    
    <div v-if="createUser.data" class="success">
      User created: {{ createUser.data.name }}
    </div>
  </form>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useMutation } from '@kitbag/query'
import { createUserMutation } from '@/mutations/users'

const name = ref('')
const email = ref('')

const createUser = useMutation(createUserMutation)

async function handleSubmit() {
  try {
    await createUser.mutate({ name: name.value, email: email.value })
    name.value = ''
    email.value = ''
  } catch (error) {
    // Error is already available in createUser.error
    console.error('Failed to create user:', error)
  }
}
</script>

Return Value

useMutation returns a reactive object with the following properties:

State Properties

ts
interface MutationResult<TData, TVariables> {
  // Data returned from the mutation
  data: Ref<TData | undefined>
  
  // Whether the mutation is currently executing
  executing: Ref<boolean>
  
  // Whether the mutation has been executed at least once
  executed: Ref<boolean>
  
  // Error if the mutation failed
  error: Ref<unknown>
  
  // Whether the mutation has errored
  errored: Ref<boolean>
  
  // Function to execute the mutation
  mutate: (...args: TVariables) => Promise<TData>
}

Usage Example

vue
<script setup lang="ts">
const updateUser = useMutation(updateUserMutation)

// Access reactive properties
console.log(updateUser.data.value)      // Result data
console.log(updateUser.executing.value) // Loading state
console.log(updateUser.error.value)     // Error state

// Execute the mutation
await updateUser.mutate(userId, userData)
</script>

Configuration Options

Basic Options

ts
const createUser = useMutation(createUserMutation, {
  // Placeholder data while executing
  placeholder: { id: 0, name: 'Creating...', email: '' }
})

Callback Options

ts
const createUser = useMutation(createUserMutation, {
  // Called when mutation starts
  onExecute: ({ payload }) => {
    console.log('Creating user with data:', payload[0])
  },
  
  // Called when mutation succeeds
  onSuccess: ({ data, payload }) => {
    console.log('User created:', data)
    showSuccessToast('User created successfully!')
  },
  
  // Called when mutation fails
  onError: ({ error, payload }) => {
    console.error('Failed to create user:', error)
    showErrorToast('Failed to create user')
  }
})

Retry Options

ts
const createUser = useMutation(createUserMutation, {
  // Number of retry attempts
  retry: 3,
  
  // Custom retry logic
  retry: (failureCount, error) => {
    // Only retry on network errors
    return failureCount < 3 && error.name === 'NetworkError'
  },
  
  // Retry delay function
  retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000)
})

Mutation Functions

Define mutations using the mutation function:

ts
// mutations/users.ts
import { mutation } from '@/queryClient'

interface CreateUserData {
  name: string
  email: string
}

export const createUserMutation = mutation(async (userData: CreateUserData) => {
  const response = await fetch('/api/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(userData)
  })
  
  if (!response.ok) {
    throw new Error('Failed to create user')
  }
  
  return response.json()
})

export const updateUserMutation = mutation(async (id: number, userData: Partial<CreateUserData>) => {
  const response = await fetch(`/api/users/${id}`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(userData)
  })
  
  if (!response.ok) {
    throw new Error('Failed to update user')
  }
  
  return response.json()
})

export const deleteUserMutation = mutation(async (id: number) => {
  const response = await fetch(`/api/users/${id}`, {
    method: 'DELETE'
  })
  
  if (!response.ok) {
    throw new Error('Failed to delete user')
  }
  
  return { id }
})

Error Handling

Handle mutation errors gracefully:

vue
<template>
  <div>
    <button @click="handleDelete" :disabled="deleteUser.executing">
      Delete User
    </button>
    
    <div v-if="deleteUser.error" class="error">
      <p>{{ deleteUser.error.message }}</p>
      <button @click="handleDelete">Retry</button>
    </div>
  </div>
</template>

<script setup lang="ts">
const deleteUser = useMutation(deleteUserMutation, {
  onError: ({ error }) => {
    // Handle specific error types
    if (error.status === 403) {
      showToast('You do not have permission to delete this user')
    } else if (error.status === 404) {
      showToast('User not found')
    } else {
      showToast('Failed to delete user')
    }
  }
})

async function handleDelete() {
  try {
    await deleteUser.mutate(userId)
    // Success - maybe navigate away or refresh data
    router.push('/users')
  } catch (error) {
    // Error is already handled by onError callback
  }
}
</script>

Loading States

Show appropriate loading states during mutations:

vue
<template>
  <div>
    <form @submit.prevent="handleSubmit">
      <!-- Form fields -->
      
      <button 
        type="submit" 
        :disabled="createUser.executing"
        :class="{ 'loading': createUser.executing }"
      >
        <span v-if="createUser.executing">
          Creating...
        </span>
        <span v-else>
          Create User
        </span>
      </button>
    </form>
    
    <!-- Show success state -->
    <div v-if="createUser.executed && !createUser.error" class="success">
      User created successfully!
    </div>
  </div>
</template>

<style scoped>
.loading {
  opacity: 0.6;
  cursor: not-allowed;
}
</style>

Optimistic Updates

Update the UI immediately, then revert if the mutation fails:

vue
<script setup lang="ts">
import { useQueryClient } from '@kitbag/query'

const queryClient = useQueryClient()

const updateUser = useMutation(updateUserMutation, {
  onExecute: ({ payload }) => {
    const [userId, userData] = payload
    
    // Optimistically update the cache
    queryClient.setQueryData(userQuery, userId, (oldData) => ({
      ...oldData,
      ...userData
    }))
  },
  
  onError: ({ payload }) => {
    const [userId] = payload
    
    // Revert optimistic update on error
    queryClient.invalidateQueries(userQuery, userId)
  },
  
  onSuccess: ({ payload }) => {
    const [userId] = payload
    
    // Ensure we have the latest data from server
    queryClient.invalidateQueries(userQuery, userId)
  }
})
</script>

Cache Invalidation

Invalidate related queries after successful mutations:

ts
const createUser = useMutation(createUserMutation, {
  onSuccess: () => {
    // Invalidate users list query
    queryClient.invalidateQueries(usersQuery)
    
    // Invalidate user count query
    queryClient.invalidateQueries(userCountQuery)
  }
})

const updateUser = useMutation(updateUserMutation, {
  onSuccess: ({ data }) => {
    // Update specific user in cache
    queryClient.setQueryData(userQuery, data.id, data)
    
    // Invalidate users list to reflect changes
    queryClient.invalidateQueries(usersQuery)
  }
})

const deleteUser = useMutation(deleteUserMutation, {
  onSuccess: ({ payload }) => {
    const [userId] = payload
    
    // Remove user from cache
    queryClient.removeQueries(userQuery, userId)
    
    // Invalidate related queries
    queryClient.invalidateQueries(usersQuery)
  }
})

Form Integration

Integrate mutations with form libraries:

With VeeValidate

vue
<template>
  <form @submit="onSubmit">
    <Field name="name" v-slot="{ field, errorMessage }">
      <input v-bind="field" placeholder="Name" />
      <span v-if="errorMessage" class="error">{{ errorMessage }}</span>
    </Field>
    
    <Field name="email" v-slot="{ field, errorMessage }">
      <input v-bind="field" placeholder="Email" />
      <span v-if="errorMessage" class="error">{{ errorMessage }}</span>
    </Field>
    
    <button type="submit" :disabled="createUser.executing">
      {{ createUser.executing ? 'Creating...' : 'Create User' }}
    </button>
  </form>
</template>

<script setup lang="ts">
import { useForm, Field } from 'vee-validate'
import * as yup from 'yup'

const schema = yup.object({
  name: yup.string().required(),
  email: yup.string().email().required()
})

const { handleSubmit } = useForm({ validationSchema: schema })

const createUser = useMutation(createUserMutation)

const onSubmit = handleSubmit(async (values) => {
  await createUser.mutate(values)
})
</script>

With Vue 3 Reactivity

vue
<script setup lang="ts">
import { reactive } from 'vue'

const form = reactive({
  name: '',
  email: '',
  errors: {}
})

const createUser = useMutation(createUserMutation, {
  onSuccess: () => {
    // Reset form on success
    form.name = ''
    form.email = ''
    form.errors = {}
  },
  
  onError: ({ error }) => {
    // Handle validation errors
    if (error.status === 422) {
      form.errors = error.data.errors
    }
  }
})

async function handleSubmit() {
  form.errors = {}
  
  try {
    await createUser.mutate({
      name: form.name,
      email: form.email
    })
  } catch (error) {
    // Error handled by onError callback
  }
}
</script>

Multiple Mutations

Handle multiple related mutations:

vue
<script setup lang="ts">
const createUser = useMutation(createUserMutation)
const updateUser = useMutation(updateUserMutation)
const deleteUser = useMutation(deleteUserMutation)

// Check if any mutation is executing
const isAnyExecuting = computed(() => 
  createUser.executing.value || 
  updateUser.executing.value || 
  deleteUser.executing.value
)

// Check if any mutation has errored
const hasErrors = computed(() =>
  createUser.errored.value ||
  updateUser.errored.value ||
  deleteUser.errored.value
)
</script>

TypeScript Support

useMutation provides full type safety:

ts
interface CreateUserData {
  name: string
  email: string
}

interface User {
  id: number
  name: string
  email: string
  createdAt: string
}

const createUserMutation = mutation(
  async (data: CreateUserData): Promise<User> => {
    // ... mutation logic
  }
)

const createUser = useMutation(createUserMutation)

// TypeScript knows:
// createUser.mutate: (data: CreateUserData) => Promise<User>
// createUser.data: Ref<User | undefined>

Best Practices

1. Handle Loading States

ts
// ✅ Always disable buttons during execution
<button :disabled="mutation.executing">
  {{ mutation.executing ? 'Saving...' : 'Save' }}
</button>

2. Provide User Feedback

ts
const createUser = useMutation(createUserMutation, {
  onSuccess: () => {
    showToast('User created successfully!', 'success')
  },
  onError: () => {
    showToast('Failed to create user', 'error')
  }
})
ts
const updateUser = useMutation(updateUserMutation, {
  onSuccess: ({ data }) => {
    // Update the specific user
    queryClient.setQueryData(userQuery, data.id, data)
    
    // Invalidate lists that might include this user
    queryClient.invalidateQueries(usersQuery)
  }
})

4. Handle Validation Errors

ts
const createUser = useMutation(createUserMutation, {
  onError: ({ error }) => {
    if (error.status === 422) {
      // Handle validation errors
      setFormErrors(error.data.errors)
    } else {
      // Handle other errors
      showToast('An unexpected error occurred')
    }
  }
})

5. Use Optimistic Updates Carefully

ts
// ✅ Only for operations likely to succeed
const likePost = useMutation(likePostMutation, {
  onExecute: ({ payload }) => {
    // Optimistically increment like count
    queryClient.setQueryData(postQuery, payload[0], (old) => ({
      ...old,
      likes: old.likes + 1,
      isLiked: true
    }))
  }
})

Common Patterns

Bulk Operations

vue
<script setup lang="ts">
const bulkDelete = useMutation(bulkDeleteMutation)

const selectedItems = ref([])

async function deleteSelected() {
  try {
    await bulkDelete.mutate(selectedItems.value.map(item => item.id))
    selectedItems.value = []
  } catch (error) {
    console.error('Bulk delete failed:', error)
  }
}
</script>

File Upload

vue
<script setup lang="ts">
const uploadFile = useMutation(uploadFileMutation, {
  onExecute: () => {
    uploadProgress.value = 0
  }
})

async function handleFileUpload(file: File) {
  const formData = new FormData()
  formData.append('file', file)
  
  await uploadFile.mutate(formData)
}
</script>

Sequential Mutations

vue
<script setup lang="ts">
const createUser = useMutation(createUserMutation)
const sendWelcomeEmail = useMutation(sendWelcomeEmailMutation)

async function createUserAndSendEmail(userData) {
  try {
    const user = await createUser.mutate(userData)
    await sendWelcomeEmail.mutate(user.id)
  } catch (error) {
    console.error('Failed to create user or send email:', error)
  }
}
</script>

Next Steps