Source:
ocean/docs/patterns/react-mutations.md| ✏️ Edit on GitHub
React Mutations Pattern Reference
Quick Reference: All mutations use useMutationWithToast for consistent success/error handling and automatic query invalidation.
Standard Pattern
import { useMutationWithToast } from '@/hooks/use-mutation-with-toast'
const Component = () => {
const createOrg = useMutationWithToast({
mutationFn: async (data: CreateOrgData) => {
const response = await fetch('/api/organizations', {
method: 'POST',
body: JSON.stringify(data)
})
if (!response.ok) throw new Error('Failed to create organization')
return response.json()
},
successMessage: 'Organization created successfully!',
invalidateQueries: [['organizations'], ['user-profile']]
})
const handleSubmit = (data: CreateOrgData) => {
createOrg.mutate(data)
}
return (
<button
onClick={() => handleSubmit(formData)}
disabled={createOrg.isPending}
>
{createOrg.isPending ? 'Creating...' : 'Create Organization'}
</button>
)
}
useMutationWithToast API
Basic Options
interface MutationWithToastOptions<TData, TError, TVariables> {
// Required
mutationFn: (variables: TVariables) => Promise<TData>
// Success handling
successMessage?: string | ((data: TData) => string)
onSuccess?: (data: TData, variables: TVariables) => void
// Error handling
errorMessage?: string | ((error: TError) => string)
onError?: (error: TError, variables: TVariables) => void
// Query invalidation
invalidateQueries?: QueryKey[]
// TanStack Query options
...UseMutationOptions<TData, TError, TVariables>
}
Return Value
interface MutationWithToastResult<TData, TError, TVariables> {
mutate: (variables: TVariables) => void
mutateAsync: (variables: TVariables) => Promise<TData>
isPending: boolean
isError: boolean
isSuccess: boolean
error: TError | null
data: TData | undefined
reset: () => void
}
Success Message Patterns
Static Message
const mutation = useMutationWithToast({
mutationFn: updateProfile,
successMessage: 'Profile updated successfully!',
})
Dynamic Message
const mutation = useMutationWithToast({
mutationFn: createOrganization,
successMessage: (data) => `Organization "${data.name}" created!`,
})
No Success Toast
const mutation = useMutationWithToast({
mutationFn: backgroundSync,
// No successMessage = no toast shown
})
Query Invalidation Patterns
Single Query
const mutation = useMutationWithToast({
mutationFn: updateOrg,
invalidateQueries: [['organization', orgId]],
})
Multiple Queries
const mutation = useMutationWithToast({
mutationFn: createMember,
invalidateQueries: [['organization', orgId, 'members'], ['user-profile'], ['organization-stats']],
})
No Invalidation
const mutation = useMutationWithToast({
mutationFn: logAnalytics,
// No invalidateQueries = no cache invalidation
})
Error Handling Patterns
Default Error Handling
// Shows generic "Something went wrong" toast
const mutation = useMutationWithToast({
mutationFn: riskyOperation,
})
Custom Error Message
const mutation = useMutationWithToast({
mutationFn: deleteResource,
errorMessage: 'Failed to delete resource. Please try again.',
})
Dynamic Error Message
const mutation = useMutationWithToast({
mutationFn: validateData,
errorMessage: (error) => {
if (error.code === 'VALIDATION_ERROR') {
return `Validation failed: ${error.details}`
}
return 'Something went wrong'
},
})
Custom Error Handling
const mutation = useMutationWithToast({
mutationFn: sensitiveOperation,
onError: (error) => {
// Custom error handling (logging, redirects, etc.)
if (error.code === 'UNAUTHORIZED') {
router.push('/login')
}
},
})
Advanced Patterns
Optimistic Updates
const updateTodo = useMutationWithToast({
mutationFn: async (todo: Todo) => {
const response = await updateTodoApi(todo)
return response.data
},
onMutate: async (newTodo) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['todos'] })
// Snapshot previous value
const previousTodos = queryClient.getQueryData(['todos'])
// Optimistically update
queryClient.setQueryData(['todos'], (old: Todo[]) =>
old?.map((todo) => (todo.id === newTodo.id ? newTodo : todo))
)
return { previousTodos }
},
onError: (err, newTodo, context) => {
// Rollback on error
queryClient.setQueryData(['todos'], context?.previousTodos)
},
onSettled: () => {
// Always refetch after error or success
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
Form Integration with TanStack Form
import { useForm } from '@tanstack/react-form'
import { valibotValidator } from '@tanstack/valibot-form-adapter'
const CreateOrgForm = () => {
const createOrg = useMutationWithToast({
mutationFn: createOrganization,
successMessage: 'Organization created!',
invalidateQueries: [['organizations']]
})
const form = useForm({
defaultValues: { name: '', description: '' },
validatorAdapter: valibotValidator(),
validators: {
onChange: CreateOrgSchema
},
onSubmit: async ({ value }) => {
await createOrg.mutateAsync(value)
// Form handles success/error states automatically
}
})
return (
<form.Provider>
<form onSubmit={(e) => {
e.preventDefault()
form.handleSubmit()
}}>
{/* Form fields */}
<form.Subscribe selector={(state) => [state.canSubmit, state.isSubmitting]}>
{([canSubmit, isSubmitting]) => (
<button
type="submit"
disabled={!canSubmit || createOrg.isPending}
>
{createOrg.isPending ? 'Creating...' : 'Create Organization'}
</button>
)}
</form.Subscribe>
</form>
</form.Provider>
)
}
Conditional Success Actions
const inviteMember = useMutationWithToast({
mutationFn: sendInvitation,
successMessage: 'Invitation sent!',
onSuccess: (data, variables) => {
// Close modal only on success
if (modalRef.current) {
modalRef.current.close()
}
// Navigate on success
if (data.shouldRedirect) {
router.push(`/organization/${data.orgId}`)
}
},
invalidateQueries: [['organization', 'members']],
})
Common Patterns by Feature
Organization Management
// Create organization
const createOrg = useMutationWithToast({
mutationFn: createOrganization,
successMessage: (data) => `"${data.name}" created successfully!`,
invalidateQueries: [['organizations'], ['user-profile']],
})
// Update organization
const updateOrg = useMutationWithToast({
mutationFn: updateOrganization,
successMessage: 'Organization updated!',
invalidateQueries: [['organization', orgId]],
})
// Delete organization
const deleteOrg = useMutationWithToast({
mutationFn: deleteOrganization,
successMessage: 'Organization deleted',
onSuccess: () => router.push('/dashboard'),
invalidateQueries: [['organizations']],
})
Member Management
// Invite member
const inviteMember = useMutationWithToast({
mutationFn: inviteMember,
successMessage: 'Invitation sent!',
invalidateQueries: [['organization', orgId, 'members']],
})
// Remove member
const removeMember = useMutationWithToast({
mutationFn: removeMember,
successMessage: 'Member removed',
invalidateQueries: [
['organization', orgId, 'members'],
['organization-stats', orgId],
],
})
// Update member role
const updateRole = useMutationWithToast({
mutationFn: updateMemberRole,
successMessage: 'Role updated successfully!',
invalidateQueries: [['organization', orgId, 'members']],
})
Billing Operations
// Update subscription
const updateSubscription = useMutationWithToast({
mutationFn: updateStripeSubscription,
successMessage: 'Subscription updated!',
invalidateQueries: [
['subscription', orgId],
['billing-history', orgId],
['organization', orgId],
],
})
// Cancel subscription
const cancelSubscription = useMutationWithToast({
mutationFn: cancelStripeSubscription,
successMessage: 'Subscription cancelled',
invalidateQueries: [['subscription', orgId]],
})
Testing Mutations
Unit Tests
import { renderHook, waitFor } from '@testing-library/react'
import { useMutationWithToast } from '@/hooks/use-mutation-with-toast'
import { createQueryWrapper } from '@/test-utils'
test('creates organization successfully', async () => {
const mockMutationFn = vi.fn().mockResolvedValue({ id: '1', name: 'Test Org' })
const { result } = renderHook(
() =>
useMutationWithToast({
mutationFn: mockMutationFn,
successMessage: 'Success!',
}),
{ wrapper: createQueryWrapper() }
)
result.current.mutate({ name: 'Test Org' })
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
expect(mockMutationFn).toHaveBeenCalledWith({ name: 'Test Org' })
})
Integration Tests
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { CreateOrgForm } from './CreateOrgForm'
test('creates organization and shows success toast', async () => {
render(<CreateOrgForm />)
fireEvent.change(screen.getByLabelText(/name/i), {
target: { value: 'New Organization' }
})
fireEvent.click(screen.getByRole('button', { name: /create/i }))
await waitFor(() => {
expect(screen.getByText('Organization created!')).toBeInTheDocument()
})
})
File Organization
src/hooks/
├── use-mutation-with-toast.ts # Base hook
├── organizations/
│ ├── use-create-organization.ts
│ ├── use-update-organization.ts
│ └── use-delete-organization.ts
├── members/
│ ├── use-invite-member.ts
│ └── use-remove-member.ts
└── billing/
├── use-update-subscription.ts
└── use-cancel-subscription.ts
Custom Hook Pattern
// src/hooks/organizations/use-create-organization.ts
export const useCreateOrganization = () => {
return useMutationWithToast({
mutationFn: async (data: CreateOrgRequest) => {
const response = await graphqlClient.request(CREATE_ORGANIZATION, data)
return response.createOrganization
},
successMessage: (data) => `"${data.name}" created successfully!`,
invalidateQueries: [['organizations'], ['user-profile']]
})
}
// Usage in component
const Component = () => {
const createOrg = useCreateOrganization()
return (
<button onClick={() => createOrg.mutate(formData)}>
Create
</button>
)
}
Common Mistakes
❌ Don't use raw useMutation:
// Wrong
const mutation = useMutation({
mutationFn: createOrg,
onSuccess: () => {
toast.success('Created!') // Manual toast
queryClient.invalidateQueries(['organizations']) // Manual invalidation
},
})
✅ Use useMutationWithToast:
// Correct
const mutation = useMutationWithToast({
mutationFn: createOrg,
successMessage: 'Created!',
invalidateQueries: [['organizations']],
})
❌ Don't forget query invalidation:
// Wrong - stale data
const mutation = useMutationWithToast({
mutationFn: updateOrg,
successMessage: 'Updated!',
// Missing: invalidateQueries
})
❌ Don't use incorrect query keys:
// Wrong - typo in query key
const mutation = useMutationWithToast({
mutationFn: updateOrg,
invalidateQueries: [['organisation']], // Should be 'organization'
})