Mutation Strategies
Understanding configs, updaters, and when to use each approach
Mutation Strategies in Relay
Master mutation strategies with free flashcards and spaced repetition practice to cement your learning. This lesson covers optimistic updates, mutation queuing, error handling patterns, and state reconciliationβessential techniques for building responsive, reliable data-writing experiences in Relay applications.
Welcome to Data Mutation Mastery π»
Writing data in modern applications isn't just about sending requests to a serverβit's about creating seamless user experiences that feel instant, handle failures gracefully, and keep your UI in perfect sync with reality. Relay mutations provide a sophisticated framework for managing these challenges, but knowing which strategy to apply when makes the difference between clunky applications and delightful ones.
In this lesson, you'll learn how to architect mutations that handle everything from simple form submissions to complex multi-step operations, all while keeping your users informed and your data consistent.
Core Concepts
Understanding Mutation Architecture ποΈ
A mutation in Relay is more than just an API callβit's a complete lifecycle that includes:
- Preparation: Gathering input data and configuring the mutation
- Optimistic response: Immediately updating the UI before server confirmation
- Network request: Sending the operation to your GraphQL server
- Reconciliation: Merging server responses with local state
- Error recovery: Handling failures and rollback scenarios
Think of mutations like bank transactions. When you transfer money via mobile banking, the app immediately shows the updated balance (optimistic update), sends the request to the bank's servers (network request), and if something fails, reverts the display and shows an error (error recovery).
The Three Core Mutation Strategies π―
1. Basic Mutations (Fire-and-Wait)
The simplest approach: send the mutation and wait for the server response before updating the UI.
When to use:
- Non-critical operations where instant feedback isn't essential
- Operations that might fail due to business logic validation
- Actions where the server response contains significant new data
Example scenario: Creating a new blog post where the server generates a slug, timestamps, and ID.
const [commit, isInFlight] = useMutation(graphql`
mutation CreatePostMutation($input: CreatePostInput!) {
createPost(input: $input) {
post {
id
title
slug
createdAt
}
}
}
`);
const handleSubmit = () => {
commit({
variables: { input: { title: "My Post" } },
onCompleted: (response) => {
navigate(`/posts/${response.createPost.post.slug}`);
},
onError: (error) => {
showNotification("Failed to create post");
}
});
};
Pros: Simple, reliable, no complex state management
Cons: User waits for network round-trip, feels slower
2. Optimistic Mutations (Predict-and-Confirm)
Update the UI immediately with your best guess of what the server will return, then reconcile when the actual response arrives.
When to use:
- High-confidence operations (like/unlike, simple updates)
- Improving perceived performance
- Operations with predictable outcomes
Example scenario: Liking a postβyou know the like count will increment by 1.
const [commit] = useMutation(graphql`
mutation LikePostMutation($postId: ID!) {
likePost(postId: $postId) {
post {
id
likeCount
viewerHasLiked
}
}
}
`);
const handleLike = (postId, currentCount) => {
commit({
variables: { postId },
optimisticResponse: {
likePost: {
post: {
id: postId,
likeCount: currentCount + 1,
viewerHasLiked: true
}
}
}
});
};
π‘ Pro tip: The optimistic response structure must exactly match your mutation's return type. Use TypeScript to catch mismatches early!
Pros: Instant UI feedback, feels native and responsive
Cons: Requires careful error handling, can be jarring if prediction is wrong
3. Queued Mutations (Sequential Guarantees)
Ensure mutations execute in order, even when fired rapidly or while offline.
When to use:
- Order-dependent operations (moving items in a list)
- Preventing race conditions
- Offline-first scenarios
Example scenario: Reordering tasks in a todo list where position matters.
const [commitReorder] = useMutation(graphql`
mutation ReorderTaskMutation($taskId: ID!, $newPosition: Int!) {
reorderTask(taskId: $taskId, newPosition: $newPosition) {
task {
id
position
}
}
}
`);
const handleDragEnd = (taskId, newPosition) => {
commitReorder({
variables: { taskId, newPosition },
optimisticResponse: {
reorderTask: {
task: { id: taskId, position: newPosition }
}
},
// Relay queues mutations by default when using optimistic responses
});
};
Pros: Prevents data corruption from race conditions, safe for rapid user actions
Cons: Can delay completion of later mutations if early ones are slow
Updater Functions: Teaching Relay About Side Effects π
Sometimes the server response alone doesn't tell Relay everything that changed. Updater functions let you manually modify the local store.
Common use cases:
| Scenario | Why Updater Needed | Strategy |
|---|---|---|
| Adding item to list | Server returns item but not updated list | Insert record into connection |
| Deleting item | Server confirms deletion with no return data | Remove record from store |
| Updating related records | Mutation affects multiple entities | Update multiple store records |
| Invalidating cache | Data staleness after mutation | Trigger refetch or clear fields |
Example: Adding a comment to a post
const [commitComment] = useMutation(graphql`
mutation AddCommentMutation($input: AddCommentInput!) {
addComment(input: $input) {
comment {
id
text
author { name }
createdAt
}
post {
id
commentCount
}
}
}
`);
const handleAddComment = (postId, text) => {
commit({
variables: { input: { postId, text } },
updater: (store) => {
const post = store.get(postId);
const newComment = store.getRootField('addComment').getLinkedRecord('comment');
// Add comment to post's comment connection
const comments = post.getLinkedRecord('comments');
const edges = comments.getLinkedRecords('edges');
const newEdge = store.create('CommentEdge', 'client:newEdge:' + newComment.getDataID());
newEdge.setLinkedRecord(newComment, 'node');
comments.setLinkedRecords([newEdge, ...edges], 'edges');
// Update comment count
const currentCount = post.getValue('commentCount');
post.setValue(currentCount + 1, 'commentCount');
}
});
};
β οΈ Common mistake: Forgetting that updater runs after optimistic response. For instant UI updates, you need both optimisticUpdater and updater.
Error Handling Patterns π¨
Mutations fail. Networks drop. Servers crash. Your strategy for handling these realities defines user experience quality.
Pattern 1: Graceful Degradation
Show the error but keep the UI functional.
commit({
variables: { input },
onError: (error) => {
// Log for debugging
console.error('Mutation failed:', error);
// Show user-friendly message
toast.error('Could not save changes. Please try again.');
// Optionally: save to retry queue
retryQueue.add({ mutation: 'likePost', variables: { input } });
}
});
Pattern 2: Automatic Retry with Exponential Backoff
const commitWithRetry = (config, attempt = 1) => {
commit({
...config,
onError: (error) => {
if (attempt < 3 && isNetworkError(error)) {
const delay = Math.pow(2, attempt) * 1000; // 2s, 4s, 8s
setTimeout(() => {
commitWithRetry(config, attempt + 1);
}, delay);
} else {
config.onError?.(error);
}
}
});
};
Pattern 3: Rollback on Failure
When using optimistic updates, revert changes if the mutation fails.
const [likeCount, setLikeCount] = useState(initialCount);
const handleLike = () => {
const previousCount = likeCount;
setLikeCount(prev => prev + 1); // Optimistic local update
commit({
variables: { postId },
optimisticResponse: { /* ... */ },
onError: () => {
setLikeCount(previousCount); // Manual rollback
toast.error('Failed to like post');
}
});
};
π‘ Pro tip: Relay automatically rolls back optimistic responses on error, but any local component state (like the example above) needs manual handling.
Optimistic Response Design Principles β¨
Crafting good optimistic responses is an art:
1. Be Conservative
Only predict what you're confident about. If uncertain, omit the fieldβRelay will fill it from the server response.
β Bad: Guessing server-generated timestamps
optimisticResponse: {
createPost: {
post: {
createdAt: new Date().toISOString() // Might not match server time!
}
}
}
β Good: Let server provide uncertain values
optimisticResponse: {
createPost: {
post: {
title: userInput,
status: 'DRAFT'
// Omit createdAt, id, slug - server generates these
}
}
}
2. Match Server Logic Exactly
Your optimistic response should mirror what your server will actually return.
3. Consider Edge Cases
What if the like count is already at max? What if the user already performed this action? Build these checks into your optimistic logic.
const handleLike = (post) => {
if (post.viewerHasLiked) {
// Already liked - this would be an unlike
optimisticResponse: {
likePost: {
post: {
id: post.id,
likeCount: Math.max(0, post.likeCount - 1),
viewerHasLiked: false
}
}
}
}
};
Mutation Configs: Declarative Store Updates π
For common patterns, Relay provides mutation configs that handle store updates automatically without writing updater functions.
| Config Type | Use Case | Required Fields |
|---|---|---|
NODE_DELETE |
Remove a record | deletedIDFieldName |
RANGE_ADD |
Add to connection | parentID, connectionInfo, edgeName |
RANGE_DELETE |
Remove from connection | parentID, connectionKeys, deletedIDFieldName |
Example: Delete mutation with automatic cleanup
commit({
variables: { id: commentId },
configs: [{
type: 'NODE_DELETE',
deletedIDFieldName: 'deletedCommentId'
}],
optimisticResponse: {
deleteComment: {
deletedCommentId: commentId
}
}
});
Relay automatically removes the comment from all connections and clears it from the store.
Detailed Examples
Example 1: E-commerce Add to Cart (Optimistic with Updater) π
Scenario: User clicks "Add to Cart" on a product page. We want instant feedback but need to handle edge cases like inventory limits.
import { graphql, useMutation } from 'react-relay';
const AddToCartMutation = graphql`
mutation AddToCartMutation($productId: ID!, $quantity: Int!) {
addToCart(productId: $productId, quantity: $quantity) {
cartItem {
id
quantity
product {
id
name
price
}
}
cart {
id
totalItems
subtotal
}
}
}
`;
function ProductPage({ product }) {
const [commit, isInFlight] = useMutation(AddToCartMutation);
const handleAddToCart = () => {
const tempId = `client:cartItem:${Date.now()}`;
commit({
variables: {
productId: product.id,
quantity: 1
},
optimisticResponse: {
addToCart: {
cartItem: {
id: tempId,
quantity: 1,
product: {
id: product.id,
name: product.name,
price: product.price
}
},
cart: {
id: 'client:cart', // Known cart ID
totalItems: null, // Let server calculate
subtotal: null // Let server calculate
}
}
},
optimisticUpdater: (store) => {
const cart = store.get('client:cart');
const newItem = store.get(tempId);
// Add to cart items connection
const items = cart.getLinkedRecord('items');
const edges = items.getLinkedRecords('edges') || [];
const newEdge = store.create(`${tempId}:edge`, 'CartItemEdge');
newEdge.setLinkedRecord(newItem, 'node');
items.setLinkedRecords([newEdge, ...edges], 'edges');
},
updater: (store) => {
// Server provided real ID, connection is auto-updated
// Just update aggregate fields
const payload = store.getRootField('addToCart');
const cart = payload.getLinkedRecord('cart');
const totalItems = cart.getValue('totalItems');
// Update cart badge in navigation
const root = store.getRoot();
root.setValue(totalItems, 'cartItemCount');
},
onCompleted: () => {
toast.success('Added to cart!', {
action: { label: 'View Cart', onClick: () => navigate('/cart') }
});
},
onError: (error) => {
if (error.message.includes('OUT_OF_STOCK')) {
toast.error('Sorry, this item is out of stock');
} else if (error.message.includes('MAX_QUANTITY')) {
toast.error('Maximum quantity in cart');
} else {
toast.error('Could not add to cart. Please try again.');
}
}
});
};
return (
<button onClick={handleAddToCart} disabled={isInFlight}>
{isInFlight ? 'Adding...' : 'Add to Cart'}
</button>
);
}
Why this works:
- Instant visual feedback via optimistic response
- Proper error handling for business logic failures
- Cart totals are server-calculated (source of truth)
- Temporary IDs prevent conflicts during optimistic phase
Example 2: Real-time Collaborative Editing (Queued Mutations) π
Scenario: Multiple users editing a shared document. We need to queue edits to prevent conflicts and maintain causal ordering.
import { graphql, useMutation } from 'react-relay';
import { useRef, useCallback } from 'react';
const UpdateDocumentMutation = graphql`
mutation UpdateDocumentMutation($docId: ID!, $operations: [EditOperation!]!) {
updateDocument(docId: $docId, operations: $operations) {
document {
id
content
version
lastEditedBy {
name
}
}
}
}
`;
function CollaborativeEditor({ document }) {
const [commit] = useMutation(UpdateDocumentMutation);
const pendingOps = useRef([]);
const isCommitting = useRef(false);
const flushOperations = useCallback(() => {
if (isCommitting.current || pendingOps.current.length === 0) {
return;
}
const operations = [...pendingOps.current];
pendingOps.current = [];
isCommitting.current = true;
commit({
variables: {
docId: document.id,
operations
},
optimisticResponse: {
updateDocument: {
document: {
id: document.id,
content: applyOperations(document.content, operations),
version: document.version + 1,
lastEditedBy: currentUser
}
}
},
onCompleted: () => {
isCommitting.current = false;
// Flush any operations that accumulated during commit
flushOperations();
},
onError: (error) => {
isCommitting.current = false;
// Re-queue failed operations
pendingOps.current = [...operations, ...pendingOps.current];
if (error.message.includes('VERSION_CONFLICT')) {
// Refetch latest version and retry
refetchDocument().then(() => flushOperations());
}
}
});
}, [document, commit]);
const handleEdit = useCallback((operation) => {
// Queue the operation
pendingOps.current.push(operation);
// Debounced flush (batch edits within 500ms)
clearTimeout(window.editFlushTimer);
window.editFlushTimer = setTimeout(flushOperations, 500);
}, [flushOperations]);
return (
<Editor
content={document.content}
onChange={handleEdit}
/>
);
}
Why queuing matters here:
- Edits maintain causal order (edit at position 5 before edit at position 10)
- Network delays don't cause conflicts
- Batch operations reduce server load
- Version conflicts trigger refetch and replay
Example 3: Multi-Step Wizard (Dependent Mutations) π§ββοΈ
Scenario: User onboarding with profile creation β preferences selection β invitation sending. Each step depends on the previous.
import { graphql, useMutation } from 'react-relay';
import { useState } from 'react';
const CreateProfileMutation = graphql`
mutation CreateProfileMutation($input: ProfileInput!) {
createProfile(input: $input) {
profile {
id
name
email
}
}
}
`;
const SetPreferencesMutation = graphql`
mutation SetPreferencesMutation($profileId: ID!, $prefs: PreferencesInput!) {
setPreferences(profileId: $profileId, preferences: $prefs) {
profile {
id
preferences {
theme
notifications
}
}
}
}
`;
const SendInvitesMutation = graphql`
mutation SendInvitesMutation($profileId: ID!, $emails: [String!]!) {
sendInvites(profileId: $profileId, emails: $emails) {
sentCount
}
}
`;
function OnboardingWizard() {
const [commitProfile] = useMutation(CreateProfileMutation);
const [commitPreferences] = useMutation(SetPreferencesMutation);
const [commitInvites] = useMutation(SendInvitesMutation);
const [step, setStep] = useState(1);
const [profileId, setProfileId] = useState(null);
const [error, setError] = useState(null);
const handleStep1Complete = (profileData) => {
commitProfile({
variables: { input: profileData },
onCompleted: (response) => {
setProfileId(response.createProfile.profile.id);
setStep(2);
},
onError: (err) => {
setError('Failed to create profile. Please try again.');
}
});
};
const handleStep2Complete = (preferences) => {
commitPreferences({
variables: {
profileId, // From step 1
prefs: preferences
},
optimisticResponse: {
setPreferences: {
profile: {
id: profileId,
preferences
}
}
},
onCompleted: () => {
setStep(3);
},
onError: (err) => {
setError('Failed to save preferences.');
}
});
};
const handleStep3Complete = (inviteEmails) => {
if (inviteEmails.length === 0) {
// Skip invites, complete onboarding
navigate('/dashboard');
return;
}
commitInvites({
variables: {
profileId,
emails: inviteEmails
},
onCompleted: (response) => {
toast.success(`Sent ${response.sendInvites.sentCount} invitations!`);
navigate('/dashboard');
},
onError: () => {
// Non-critical - let them proceed anyway
toast.warning('Could not send invites, but your account is ready!');
navigate('/dashboard');
}
});
};
return (
<WizardUI
step={step}
error={error}
onStep1Complete={handleStep1Complete}
onStep2Complete={handleStep2Complete}
onStep3Complete={handleStep3Complete}
/>
);
}
Key patterns:
- Each mutation waits for the previous to complete (sequential dependency)
- Early steps are critical (block on failure)
- Later steps are optional (graceful degradation)
- Profile ID from step 1 is passed to subsequent mutations
Example 4: Batch Operations with Partial Failure Handling π¦
Scenario: User selects multiple items and performs a bulk action (archive, delete, tag). Some might fail due to permissions.
import { graphql, useMutation } from 'react-relay';
const BulkArchiveMutation = graphql`
mutation BulkArchiveMutation($ids: [ID!]!) {
bulkArchive(ids: $ids) {
successfulIds
failedItems {
id
error
}
archivedCount
}
}
`;
function ItemList({ items }) {
const [selectedIds, setSelectedIds] = useState([]);
const [commit, isInFlight] = useMutation(BulkArchiveMutation);
const handleBulkArchive = () => {
commit({
variables: { ids: selectedIds },
optimisticUpdater: (store) => {
// Optimistically mark all as archived
selectedIds.forEach(id => {
const record = store.get(id);
record?.setValue(true, 'isArchived');
});
},
updater: (store, data) => {
const { successfulIds, failedItems } = data.bulkArchive;
// Rollback failed items
failedItems.forEach(item => {
const record = store.get(item.id);
record?.setValue(false, 'isArchived');
});
// Update list counts
const root = store.getRoot();
const activeCount = root.getValue('activeItemCount');
root.setValue(activeCount - successfulIds.length, 'activeItemCount');
},
onCompleted: (response) => {
const { successfulIds, failedItems } = response.bulkArchive;
if (failedItems.length === 0) {
toast.success(`Archived ${successfulIds.length} items`);
} else {
toast.warning(
`Archived ${successfulIds.length} items. ` +
`${failedItems.length} failed (insufficient permissions)`
);
}
setSelectedIds([]); // Clear selection
}
});
};
return (
<div>
<ItemSelectionUI
items={items}
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
/>
<button
onClick={handleBulkArchive}
disabled={selectedIds.length === 0 || isInFlight}
>
Archive Selected ({selectedIds.length})
</button>
</div>
);
}
Why this approach works:
- Optimistically updates all items for instant feedback
- Server returns partial success information
- Updater selectively rolls back failed items
- User gets clear feedback about what succeeded/failed
Common Mistakes to Avoid β οΈ
1. Mismatched Optimistic Response Structure
β Wrong:
optimisticResponse: {
likePost: {
likeCount: 42 // Missing 'post' wrapper!
}
}
β Correct:
optimisticResponse: {
likePost: {
post: { // Matches mutation return type
id: postId,
likeCount: 42,
viewerHasLiked: true
}
}
}
Why it matters: Structure must exactly match your GraphQL mutation return type. Relay uses this to update the store correctly.
2. Forgetting to Handle Loading States
β Wrong:
const [commit] = useMutation(mutation);
return <button onClick={() => commit({...})}>Submit</button>;
Users can click multiple times, firing duplicate mutations.
β Correct:
const [commit, isInFlight] = useMutation(mutation);
return (
<button onClick={() => commit({...})} disabled={isInFlight}>
{isInFlight ? 'Submitting...' : 'Submit'}
</button>
);
3. Over-Optimizing with Guessed Server Values
β Wrong:
optimisticResponse: {
createUser: {
user: {
id: Math.random().toString(), // Will conflict with server ID!
createdAt: new Date().toISOString(), // Might differ from server
emailVerified: false // Business logic might set to true
}
}
}
β Correct:
optimisticResponse: {
createUser: {
user: {
name: userInput.name,
email: userInput.email
// Omit id, createdAt, emailVerified - let server provide
}
}
}
4. Ignoring Race Conditions
β Wrong: Rapidly firing mutations without queuing
items.forEach(item => {
commit({ variables: { id: item.id } }); // All fire simultaneously!
});
Later mutations might complete before earlier ones, causing wrong final state.
β Correct: Sequential or batched approach
// Option 1: Batch in single mutation
commit({ variables: { ids: items.map(i => i.id) } });
// Option 2: Sequential with await
for (const item of items) {
await commitAsync({ variables: { id: item.id } });
}
5. Not Cleaning Up on Unmount
β Wrong: Mutation callbacks fire after component unmounts
const handleSubmit = () => {
commit({
variables: { input },
onCompleted: () => {
setSuccess(true); // Error if component unmounted!
}
});
};
β Correct: Check if still mounted
const isMounted = useRef(true);
useEffect(() => {
return () => { isMounted.current = false; };
}, []);
const handleSubmit = () => {
commit({
variables: { input },
onCompleted: () => {
if (isMounted.current) {
setSuccess(true);
}
}
});
};
6. Forgetting Updater for Connections
β Wrong: Adding an item but not updating the list
commit({
variables: { input },
// Mutation returns new item, but list doesn't show it!
});
β Correct: Use updater or mutation config
commit({
variables: { input },
updater: (store) => {
const newItem = store.getRootField('createItem').getLinkedRecord('item');
const list = store.getRoot().getLinkedRecord('items');
const edges = list.getLinkedRecords('edges') || [];
const newEdge = store.create('edge:' + newItem.getDataID(), 'ItemEdge');
newEdge.setLinkedRecord(newItem, 'node');
list.setLinkedRecords([newEdge, ...edges], 'edges');
}
});
7. Swallowing Errors Silently
β Wrong:
commit({
variables: { input },
onError: (error) => {
console.log(error); // User sees nothing!
}
});
β Correct:
commit({
variables: { input },
onError: (error) => {
console.error('Mutation failed:', error);
toast.error('Failed to save. Please try again.');
// Optionally: report to error tracking service
errorTracker.captureException(error);
}
});
Key Takeaways π―
π Quick Reference Card
| Strategy | Best For | Key Feature |
|---|---|---|
| Basic Mutation | Server-generated data, validation-heavy | Simple, reliable, waits for server |
| Optimistic Mutation | High-confidence operations, UX speed | Instant UI feedback, auto-rollback |
| Queued Mutation | Order-dependent, rapid user actions | Sequential execution, race-free |
| With Updater | Adding/removing from lists | Manual store manipulation |
| Mutation Configs | Common patterns (delete, add to list) | Declarative store updates |
π‘ Essential Principles
- Match server structure: Optimistic responses must exactly mirror mutation return types
- Handle all states: Loading, success, errorβnever leave users guessing
- Be conservative with predictions: Only optimistically update what you're certain about
- Clean up connections: Adding/removing items requires updaters or configs
- Queue when order matters: Prevent race conditions in rapid-fire scenarios
- Rollback gracefully: Optimistic updates should revert cleanly on failure
- Communicate clearly: Users should always know what's happening and why
π§ Decision Tree: Which Strategy?
Does the mutation affect a list/connection?
β
ββ YES β Need updater or mutation config
β β
β ββ Is it add/delete? β Use RANGE_ADD / NODE_DELETE config
β ββ Complex logic? β Write custom updater
β
ββ NO β Is instant feedback critical?
β
ββ YES β Can you predict the result?
β β
β ββ YES β Use optimistic response
β ββ NO β Basic mutation + loading state
β
ββ NO β Basic mutation
Do rapid user actions cause conflicts?
β
ββ YES β Ensure queuing (optimistic responses auto-queue)
β‘ Performance Tips
- Batch operations: Combine multiple mutations when possible
- Debounce inputs: Wait for user to finish typing before mutating
- Partial updates: Only send changed fields, not entire objects
- Cache-first reads: After mutation, read from store before network
π Further Study
Official Relay Documentation
- Relay Mutations Guide - Comprehensive mutation patterns and best practices
- Relay Store API - Deep dive into updater functions and store manipulation
- Optimistic Updates - Detailed guide to optimistic UI patterns
Remember: The best mutation strategy depends on your specific use case. Start simple with basic mutations, then add optimistic updates where responsiveness matters most. Always prioritize clear user communication over clever technical tricks! π