Skip to main content

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'
})