Skip to main content

Dependent Mutations: Sequential Writes Tutorial

Some workflows require strict ordering: create a user, then create their profile, then send an invitation email. If the user creation fails, the profile should never be created. React Query's mutateAsync lets you chain mutations sequentially, passing the result of one as input to the next, with proper error handling and rollback. This article covers the patterns for building robust, composable write sequences.

Basic Sequential Mutations with mutateAsync

The simplest pattern is to use mutateAsync and await in an async function. Each mutation waits for the previous one to complete.

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

export default function CreateUserWorkflow() {
const queryClient = useQueryClient();

// Mutation 1: Create user account
const createUserMutation = useMutation({
mutationFn: async (email) => {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
if (!res.ok) throw new Error('User creation failed');
return res.json();
},
});

// Mutation 2: Create user profile
const createProfileMutation = useMutation({
mutationFn: async ({ userId, name, bio }) => {
const res = await fetch('/api/profiles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, name, bio }),
});
if (!res.ok) throw new Error('Profile creation failed');
return res.json();
},
});

// Mutation 3: Send welcome email
const sendEmailMutation = useMutation({
mutationFn: async ({ userId, email }) => {
const res = await fetch('/api/emails/welcome', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, email }),
});
if (!res.ok) throw new Error('Email send failed');
return res.json();
},
});

const handleWorkflow = async (email, name, bio) => {
try {
// Step 1: Create user
const user = await createUserMutation.mutateAsync(email);
console.log('User created:', user);

// Step 2: Create profile (depends on user.id)
const profile = await createProfileMutation.mutateAsync({
userId: user.id,
name,
bio,
});
console.log('Profile created:', profile);

// Step 3: Send welcome email
const emailResult = await sendEmailMutation.mutateAsync({
userId: user.id,
email,
});
console.log('Email sent:', emailResult);

// Success: invalidate queries to refresh UI
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['profiles'] });
} catch (error) {
console.error('Workflow failed:', error.message);
// At this point, partial data may exist on the server
// (e.g., user created but profile not). You may need to clean up.
}
};

const isLoading = createUserMutation.isPending || createProfileMutation.isPending || sendEmailMutation.isPending;

return (
<button onClick={() => handleWorkflow('[email protected]', 'Alice', 'Love coding')} disabled={isLoading}>
{isLoading ? 'Creating...' : 'Create User'}
</button>
);
}

Conditional Mutations: Skip Steps Based on Results

Sometimes a mutation's result determines whether to run the next step.

const publishWorkflow = async (postId) => {
try {
// Step 1: Check if post is already published
const post = await getPostQuery.mutateAsync(postId);
if (post.isPublished) {
console.log('Post already published');
return;
}

// Step 2: Run auto-moderation check
const modResult = await moderateMutation.mutateAsync({ postId });
if (!modResult.isApproved) {
throw new Error('Post flagged by moderation; not publishing.');
}

// Step 3: Publish the post
const published = await publishMutation.mutateAsync(postId);
console.log('Published:', published);

// Step 4: Send notification (conditionally)
if (post.notifySubscribers) {
await notifyMutation.mutateAsync(postId);
}
} catch (error) {
console.error('Publish workflow failed:', error);
}
};

Parallel Mutations with Dependencies

Use Promise.all to run independent mutations in parallel, then chain dependent ones.

const createProjectWorkflow = async (projectData) => {
try {
// Step 1: Create project and team in parallel
const [project, team] = await Promise.all([
createProjectMutation.mutateAsync(projectData),
createTeamMutation.mutateAsync({ name: projectData.teamName }),
]);

// Step 2: Link the team to the project (depends on both IDs)
const linked = await linkTeamMutation.mutateAsync({
projectId: project.id,
teamId: team.id,
});

console.log('Project and team created and linked:', linked);
} catch (error) {
console.error('Project setup failed:', error);
}
};

Error Handling and Rollback in Chains

If an error occurs mid-chain, you may need to rollback earlier steps. Use a cleanup function.

const createOrderWorkflow = async (orderData) => {
let orderId;
let paymentId;

try {
// Step 1: Create order
const order = await createOrderMutation.mutateAsync(orderData);
orderId = order.id;

// Step 2: Process payment
const payment = await processPaymentMutation.mutateAsync({
orderId: order.id,
amount: orderData.amount,
token: orderData.paymentToken,
});
paymentId = payment.id;

// Step 3: Send confirmation email
await sendConfirmationMutation.mutateAsync(order.id);

console.log('Order workflow complete');
} catch (error) {
console.error('Order workflow failed:', error);

// Cleanup: rollback steps that succeeded
if (paymentId) {
try {
await refundPaymentMutation.mutateAsync(paymentId);
} catch (refundError) {
console.error('Refund also failed; manual intervention needed', refundError);
}
}

if (orderId) {
try {
await cancelOrderMutation.mutateAsync(orderId);
} catch (cancelError) {
console.error('Cancel also failed; manual intervention needed', cancelError);
}
}

throw error; // Re-throw so caller can handle
}
};

Composing Mutations into a Custom Hook

For reusability, wrap a mutation sequence in a custom hook.

function useCreateBlogPost() {
const queryClient = useQueryClient();

const createPostMutation = useMutation({
mutationFn: async (title) => {
const res = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
if (!res.ok) throw new Error('Post creation failed');
return res.json();
},
});

const uploadCoverMutation = useMutation({
mutationFn: async ({ postId, image }) => {
const formData = new FormData();
formData.append('image', image);
const res = await fetch(`/api/posts/${postId}/cover`, {
method: 'POST',
body: formData,
});
if (!res.ok) throw new Error('Cover upload failed');
return res.json();
},
});

const setPublishedMutation = useMutation({
mutationFn: async (postId) => {
const res = await fetch(`/api/posts/${postId}/publish`, {
method: 'POST',
});
if (!res.ok) throw new Error('Publish failed');
return res.json();
},
});

const createPost = async (title, coverImage) => {
try {
// Create post
const post = await createPostMutation.mutateAsync(title);

// Upload cover (if provided)
if (coverImage) {
await uploadCoverMutation.mutateAsync({ postId: post.id, image: coverImage });
}

// Publish
const published = await setPublishedMutation.mutateAsync(post.id);

// Invalidate to refresh
queryClient.invalidateQueries({ queryKey: ['posts'] });

return published;
} catch (error) {
throw error;
}
};

return {
createPost,
isLoading: createPostMutation.isPending || uploadCoverMutation.isPending || setPublishedMutation.isPending,
error: createPostMutation.error || uploadCoverMutation.error || setPublishedMutation.error,
};
}

// Usage
export default function BlogEditor() {
const { createPost, isLoading, error } = useCreateBlogPost();

const handlePublish = async (title, coverImage) => {
try {
const post = await createPost(title, coverImage);
console.log('Published:', post);
} catch (err) {
console.error('Failed:', err);
}
};

return (
<>
<button onClick={() => handlePublish('My Post', null)} disabled={isLoading}>
{isLoading ? 'Publishing...' : 'Publish'}
</button>
{error && <p style={{ color: 'red' }}>{error.message}</p>}
</>
);
}

Key Takeaways

  • Use mutateAsync to chain mutations: await the result of one before passing it to the next.
  • Organize steps logically; fail fast if an early step fails to avoid cascading partial data.
  • For independent steps, use Promise.all to run them in parallel; then chain dependent steps sequentially.
  • Implement rollback/cleanup for failed steps: refund payments, cancel orders, or delete partial records.
  • Wrap complex workflows in custom hooks for reusability and cleaner component code.
  • Invalidate queries after successful workflows to refresh the UI with server-confirmed data.

Frequently Asked Questions

What if I need to abort a mutation mid-chain?

Use an AbortController. Mutations support it via the mutationFn signature: mutationFn: async (vars, { signal }) => { /* use signal to cancel */ }. Return a rejected Promise to trigger error handling.

How do I show progress for a multi-step workflow?

Track which step the workflow is on using state. For example: const [step, setStep] = useState(0) and update it before each mutateAsync. Then render progress: Step ${step} of 3.

Can I retry a failed step without restarting the workflow?

Yes, but you need to track which step failed and allow manual retry. Alternatively, implement a state machine that can resume from any step.

Should I create one mutation per step or one for the entire workflow?

One per step is cleaner and more reusable. Each mutation has a single responsibility. Combine them in a hook or component for the workflow.

What if the API doesn't support atomic transactions?

Implement compensation logic: if step 2 fails, undo step 1 via a separate API call (e.g., DELETE the user created in step 1). This is expensive but necessary for correctness.

Further Reading