Writing Data Without Chaos
Master mutations, optimistic updates, and maintaining cache consistency during writes
Writing Data Without Chaos
Master Relay mutations with free flashcards and spaced repetition practice. This lesson covers mutation structure, optimistic updates, error handling, and client-side data consistency—essential concepts for building predictable React applications with GraphQL.
Welcome to Mutation Mastery 💻
If you've been querying data with Relay, you already know half the story. Mutations are where things get interesting—and where many developers encounter their first real headaches. Unlike simple queries that just read data, mutations modify your backend state and need to carefully synchronize those changes back to your client-side cache.
The "chaos" in this lesson's title isn't an exaggeration. Without proper mutation practices, you'll face:
- UI showing stale data after successful updates
- Race conditions when multiple mutations run simultaneously
- Confusing error states that leave users wondering what happened
- Cache inconsistencies that require full page refreshes
Relay provides powerful tools to prevent all of this—but you need to understand how to use them correctly.
Core Concepts: The Anatomy of a Relay Mutation 🔬
1. The Basic Mutation Structure
Every Relay mutation follows a consistent pattern. Let's break down the essential components:
GraphQL Mutation Definition:
mutation AddTodoMutation($input: AddTodoInput!) {
addTodo(input: $input) {
todoEdge {
node {
id
text
complete
}
}
viewer {
totalCount
}
}
}
Relay Mutation Implementation:
import { commitMutation } from 'react-relay';
import { graphql } from 'relay-runtime';
function addTodo(environment, text, viewerId) {
const variables = {
input: {
text,
viewerId,
},
};
commitMutation(environment, {
mutation: graphql`
mutation AddTodoMutation($input: AddTodoInput!) {
addTodo(input: $input) {
todoEdge {
node {
id
text
complete
}
}
}
}
`,
variables,
onCompleted: (response, errors) => {
console.log('Mutation completed:', response);
},
onError: (error) => {
console.error('Mutation failed:', error);
},
});
}
Key Components:
commitMutation: The function that executes your mutationenvironment: Your Relay environment instance (holds the network layer and store)variables: The input data for your mutationonCompleted: Callback when mutation succeedsonError: Callback when mutation fails
💡 Tip: Always include the id field in your mutation response—Relay uses it to update the correct records in its cache.
2. Updater Functions: Teaching Relay How to Update the Cache 🎯
This is where the magic happens. After a mutation completes, Relay needs to know how to update its client-side store. The updater function gives you direct access to the store to make these changes.
commitMutation(environment, {
mutation,
variables,
updater: (store) => {
// Get the payload from the mutation response
const payload = store.getRootField('addTodo');
const newEdge = payload.getLinkedRecord('todoEdge');
// Get the viewer's todo connection
const viewer = store.getRoot().getLinkedRecord('viewer');
const todos = viewer.getLinkedRecord('todos');
// Add the new edge to the connection
const edges = todos.getLinkedRecords('edges');
const newEdges = [...edges, newEdge];
todos.setLinkedRecords(newEdges, 'edges');
},
});
Store Methods You'll Use:
| Method | Purpose | Example |
|---|---|---|
store.get(id) |
Retrieve a record by ID | store.get('todo:123') |
record.getValue(field) |
Get a scalar field value | todo.getValue('text') |
record.setValue(value, field) |
Set a scalar field | todo.setValue(true, 'complete') |
record.getLinkedRecord(field) |
Get a related object | todo.getLinkedRecord('author') |
record.setLinkedRecord(record, field) |
Set a related object | todo.setLinkedRecord(user, 'assignee') |
3. Optimistic Updates: Feel the Speed ⚡
Users expect instant feedback. Optimistic updates let you update the UI immediately—before the server responds—then reconcile when the real response arrives.
commitMutation(environment, {
mutation,
variables,
optimisticResponse: {
addTodo: {
todoEdge: {
node: {
id: 'temp-id-' + Date.now(), // Temporary ID
text: variables.input.text,
complete: false,
},
},
},
},
optimisticUpdater: (store) => {
// Immediately update the UI with optimistic data
const newTodo = store.create('temp-id-' + Date.now(), 'Todo');
newTodo.setValue(variables.input.text, 'text');
newTodo.setValue(false, 'complete');
// Add to the list immediately
const viewer = store.getRoot().getLinkedRecord('viewer');
const todos = viewer.getLinkedRecord('todos');
const edges = todos.getLinkedRecords('edges') || [];
const newEdge = store.create('temp-edge-' + Date.now(), 'TodoEdge');
newEdge.setLinkedRecord(newTodo, 'node');
todos.setLinkedRecords([...edges, newEdge], 'edges');
},
updater: (store) => {
// This runs when the real response arrives
// Relay automatically reconciles the optimistic data with real data
},
});
The Flow:
User clicks "Add Todo"
|
v
optimisticUpdater runs
|
v
UI updates INSTANTLY
|
v
Request sent to server
|
v
Server responds (1-2 seconds later)
|
v
updater runs
|
v
Relay replaces temp IDs with real IDs
|
v
UI reflects any server corrections
💡 Tip: Always use temporary IDs for optimistic records. Relay will automatically replace them when the real response arrives.
⚠️ Common Mistake: Using the same ID in both optimistic and real updates can cause Relay to think nothing changed, leading to stale UI.
4. Declarative Mutation Configs: The Easy Path 🛤️
For common scenarios, Relay provides configs that handle cache updates automatically—no manual updater needed!
RANGE_ADD Configuration:
Use when adding items to a list:
commitMutation(environment, {
mutation,
variables,
configs: [{
type: 'RANGE_ADD',
parentID: viewerId,
connectionInfo: [{
key: 'TodoList_todos',
rangeBehavior: 'append', // or 'prepend'
}],
edgeName: 'todoEdge',
}],
});
RANGE_DELETE Configuration:
Use when removing items from a list:
commitMutation(environment, {
mutation,
variables,
configs: [{
type: 'RANGE_DELETE',
parentID: viewerId,
connectionKeys: [{
key: 'TodoList_todos',
}],
pathToConnection: ['viewer', 'todos'],
deletedIDFieldName: 'deletedTodoId',
}],
});
NODE_DELETE Configuration:
Use when deleting a single node:
commitMutation(environment, {
mutation,
variables,
configs: [{
type: 'NODE_DELETE',
deletedIDFieldName: 'deletedTodoId',
}],
});
Config Types Summary:
| Type | Use Case | Required Fields |
|---|---|---|
| RANGE_ADD | Adding to a list/connection | parentID, connectionInfo, edgeName |
| RANGE_DELETE | Removing from a list/connection | parentID, connectionKeys, deletedIDFieldName |
| NODE_DELETE | Deleting a node entirely | deletedIDFieldName |
| FIELDS_CHANGE | Simple field updates (rare) | fieldIDs |
🧠 Memory Device: Think "Range for Relationships" (lists), "Node for Nuking" (deletions).
5. Error Handling: When Things Go Wrong 🚨
Robust error handling separates good applications from great ones.
commitMutation(environment, {
mutation,
variables,
onCompleted: (response, errors) => {
if (errors) {
// GraphQL errors (validation, business logic)
errors.forEach(error => {
console.error('GraphQL Error:', error.message);
showNotification(error.message, 'error');
});
} else {
// Success!
showNotification('Todo added successfully', 'success');
}
},
onError: (error) => {
// Network errors, server crashes, etc.
console.error('Network Error:', error);
showNotification('Failed to connect. Please try again.', 'error');
},
});
Error Types:
📋 Error Categories
| Network Errors | Connection failures, timeouts → caught by onError |
| GraphQL Errors | Validation failures, business logic errors → in errors array |
| Server Errors | 500 errors, crashes → caught by onError |
Handling Optimistic Update Failures:
When an optimistic update fails, Relay automatically rolls back the changes. You should provide user feedback:
commitMutation(environment, {
mutation,
variables,
optimisticResponse: { /* ... */ },
optimisticUpdater: (store) => { /* ... */ },
onError: (error) => {
// Optimistic update was rolled back automatically
showNotification('Failed to add todo. Changes reverted.', 'error');
},
});
💡 Pro Tip: Always show visual feedback during mutations—spinners, disabled buttons, progress indicators—so users know something is happening.
Practical Examples: Real-World Mutations 🌍
Example 1: Creating a New Item (with Optimistic Update)
Let's build a complete "Add Comment" mutation:
import { commitMutation, graphql } from 'react-relay';
const mutation = graphql`
mutation AddCommentMutation($input: AddCommentInput!) {
addComment(input: $input) {
commentEdge {
node {
id
text
createdAt
author {
id
name
}
}
}
post {
commentCount
}
}
}
`;
function addComment(environment, postId, text, currentUser) {
const tempId = `temp-comment-${Date.now()}`;
commitMutation(environment, {
mutation,
variables: {
input: { postId, text },
},
optimisticResponse: {
addComment: {
commentEdge: {
node: {
id: tempId,
text,
createdAt: new Date().toISOString(),
author: {
id: currentUser.id,
name: currentUser.name,
},
},
},
post: {
id: postId,
commentCount: null, // We don't know the new count yet
},
},
},
configs: [{
type: 'RANGE_ADD',
parentID: postId,
connectionInfo: [{
key: 'Post_comments',
rangeBehavior: 'append',
}],
edgeName: 'commentEdge',
}],
onCompleted: (response, errors) => {
if (errors) {
console.error('Failed to add comment:', errors);
} else {
console.log('Comment added! New count:', response.addComment.post.commentCount);
}
},
onError: (error) => {
console.error('Network error:', error);
alert('Failed to post comment. Please check your connection.');
},
});
}
What's Happening:
- Optimistic response creates a temporary comment with current timestamp
- RANGE_ADD config automatically adds it to the post's comments list
- UI updates instantly—users see their comment immediately
- Server processes the request (1-2 seconds)
- Real response arrives with actual ID and server timestamp
- Relay replaces temp data with real data seamlessly
Example 2: Updating an Existing Item
Toggling a todo's completion status:
const mutation = graphql`
mutation ToggleTodoMutation($input: ToggleTodoInput!) {
toggleTodo(input: $input) {
todo {
id
complete
}
}
}
`;
function toggleTodo(environment, todoId, currentComplete) {
commitMutation(environment, {
mutation,
variables: {
input: { todoId },
},
optimisticResponse: {
toggleTodo: {
todo: {
id: todoId,
complete: !currentComplete, // Flip the current state
},
},
},
// No updater needed! Relay auto-updates by ID
onError: (error) => {
console.error('Toggle failed:', error);
},
});
}
Why No Updater?
Because we're updating fields on an existing record (identified by id), Relay automatically merges the changes into its cache. You only need an updater when:
- Adding/removing items from lists
- Creating new relationships
- Complex cache manipulations
Example 3: Deleting with Cleanup
Deleting a todo and removing it from all lists:
const mutation = graphql`
mutation DeleteTodoMutation($input: DeleteTodoInput!) {
deleteTodo(input: $input) {
deletedTodoId
viewer {
totalCount
}
}
}
`;
function deleteTodo(environment, todoId) {
commitMutation(environment, {
mutation,
variables: {
input: { todoId },
},
configs: [{
type: 'NODE_DELETE',
deletedIDFieldName: 'deletedTodoId',
}],
optimisticResponse: {
deleteTodo: {
deletedTodoId: todoId,
viewer: {
totalCount: null, // Server will provide the new count
},
},
},
onCompleted: (response) => {
console.log('Deleted! New total:', response.deleteTodo.viewer.totalCount);
},
onError: (error) => {
console.error('Delete failed:', error);
alert('Could not delete todo. It may have been already deleted.');
},
});
}
NODE_DELETE automatically:
- Removes the node from Relay's store
- Removes it from all connections (lists) containing it
- Updates any references to the deleted node
Example 4: Complex Update with Manual Updater
Moving a task to a different list:
const mutation = graphql`
mutation MoveTaskMutation($input: MoveTaskInput!) {
moveTask(input: $input) {
task {
id
listId
}
oldList {
id
taskCount
}
newList {
id
taskCount
}
}
}
`;
function moveTask(environment, taskId, oldListId, newListId) {
commitMutation(environment, {
mutation,
variables: {
input: { taskId, newListId },
},
updater: (store) => {
const task = store.get(taskId);
if (!task) return;
// Update the task's listId
task.setValue(newListId, 'listId');
// Remove from old list's connection
const oldList = store.get(oldListId);
const oldTasks = oldList.getLinkedRecord('tasks');
const oldEdges = oldTasks.getLinkedRecords('edges') || [];
const filteredOldEdges = oldEdges.filter(
edge => edge.getLinkedRecord('node').getDataID() !== taskId
);
oldTasks.setLinkedRecords(filteredOldEdges, 'edges');
// Add to new list's connection
const newList = store.get(newListId);
const newTasks = newList.getLinkedRecord('tasks');
const newEdges = newTasks.getLinkedRecords('edges') || [];
// Create new edge
const newEdge = store.create(`edge-${taskId}`, 'TaskEdge');
newEdge.setLinkedRecord(task, 'node');
newTasks.setLinkedRecords([...newEdges, newEdge], 'edges');
},
onCompleted: (response) => {
console.log('Task moved successfully');
},
onError: (error) => {
console.error('Move failed:', error);
},
});
}
This demonstrates the full power of manual updaters—precisely controlling how data moves through your cache.
Common Mistakes to Avoid ⚠️
1. Forgetting to Include id in Mutation Response
❌ Wrong:
mutation UpdateTodo($input: UpdateTodoInput!) {
updateTodo(input: $input) {
todo {
text
complete
}
}
}
✅ Right:
mutation UpdateTodo($input: UpdateTodoInput!) {
updateTodo(input: $input) {
todo {
id # ← ALWAYS include this!
text
complete
}
}
}
Without id, Relay can't identify which record to update in its cache.
2. Using the Same ID for Optimistic and Real Data
❌ Wrong:
optimisticResponse: {
addTodo: {
todo: {
id: 'real-id-123', // Don't use real IDs!
text: 'New todo',
},
},
}
✅ Right:
optimisticResponse: {
addTodo: {
todo: {
id: `temp-${Date.now()}`, // Use temporary ID
text: 'New todo',
},
},
}
3. Mutating Optimistic Data Before Server Response
Don't chain mutations based on optimistic data. Wait for the real response:
❌ Wrong:
addTodo(environment, 'First todo').then(() => {
// This might use the temp ID!
updateTodo(environment, tempId, 'Updated');
});
✅ Right:
commitMutation(environment, {
mutation: addTodoMutation,
variables,
onCompleted: (response) => {
const realId = response.addTodo.todo.id;
updateTodo(environment, realId, 'Updated');
},
});
4. Not Handling Errors in UI
❌ Wrong:
commitMutation(environment, {
mutation,
variables,
// No onError or onCompleted
});
✅ Right:
commitMutation(environment, {
mutation,
variables,
onCompleted: (response, errors) => {
if (errors) {
showErrorMessage(errors[0].message);
} else {
showSuccessMessage('Saved!');
}
},
onError: (error) => {
showErrorMessage('Network error. Please try again.');
},
});
5. Incorrect Connection Keys in Configs
❌ Wrong:
configs: [{
type: 'RANGE_ADD',
parentID: userId,
connectionInfo: [{
key: 'todos', // ← Wrong! Should match @connection key
rangeBehavior: 'append',
}],
}]
✅ Right:
configs: [{
type: 'RANGE_ADD',
parentID: userId,
connectionInfo: [{
key: 'UserTodoList_todos', // ← Must match @connection(key: "UserTodoList_todos")
rangeBehavior: 'append',
}],
}]
The connection key must exactly match what's in your query's @connection directive.
6. Forgetting Optimistic Updater for Optimistic Response
If your mutation needs an updater for the real response, it needs an optimistic updater too:
❌ Wrong:
commitMutation(environment, {
mutation,
variables,
optimisticResponse: { /* ... */ },
// Missing optimisticUpdater!
updater: (store) => { /* complex update logic */ },
});
✅ Right:
commitMutation(environment, {
mutation,
variables,
optimisticResponse: { /* ... */ },
optimisticUpdater: (store) => {
// Same logic as updater, but with optimistic data
},
updater: (store) => {
// Logic for real response
},
});
Key Takeaways 🎯
📋 Quick Reference Card: Relay Mutations
| Concept | Key Points |
|---|---|
| Basic Structure | Use commitMutation with mutation, variables, callbacks |
| Updater Function | Manually update cache when Relay can't infer changes |
| Optimistic Updates | Use optimisticResponse + optimisticUpdater for instant UI |
| Configs | RANGE_ADD (adding), RANGE_DELETE (removing), NODE_DELETE (deleting) |
| Error Handling | onCompleted for GraphQL errors, onError for network errors |
| Always Include | id field in mutation responses for cache updates |
| Temporary IDs | Use temp-${Date.now()} pattern for optimistic creates |
| Auto-updates | Field changes on existing records update automatically by ID |
🧠 Memory Device: MUCOE
- Mutation definition
- Updater function
- Configs (declarative)
- Optimistic updates
- Error handling
When to Use What:
Mutation Type Decision Tree
Is it a simple field update
on an existing record?
|
┌────────┴────────┐
YES NO
| |
v v
No updater Adding/deleting
needed! from lists?
|
┌────────┴────────┐
YES NO
| |
v v
Use configs Manual updater
(RANGE_ADD, (full control)
RANGE_DELETE,
NODE_DELETE)
Best Practices Checklist:
✅ Always include id in mutation responses
✅ Use temporary IDs for optimistic creates
✅ Provide both onCompleted and onError callbacks
✅ Show loading states during mutations
✅ Match connection keys exactly between queries and configs
✅ Use optimistic updates for instant feedback
✅ Test mutation error paths, not just happy paths
✅ Roll back optimistic changes gracefully on errors
📚 Further Study
Relay Official Documentation - Mutations: https://relay.dev/docs/guided-tour/updating-data/graphql-mutations/
Comprehensive guide with advanced patterns and edge casesRelay Store API Reference: https://relay.dev/docs/api-reference/store/
Complete reference for all store methods used in updatersGraphQL Mutation Best Practices: https://graphql.org/learn/queries/#mutations
Server-side mutation design patterns that work well with Relay
🎉 Congratulations! You now understand how to write data in Relay without descending into chaos. Practice with small mutations first, then gradually tackle more complex scenarios. Remember: when in doubt, add an id field and use an optimistic update. Your users will thank you for the snappy interface! 💻✨