You are viewing a preview of this lesson. Sign in to start learning
Back to Mastering Relay

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 mutation
  • environment: Your Relay environment instance (holds the network layer and store)
  • variables: The input data for your mutation
  • onCompleted: Callback when mutation succeeds
  • onError: 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:

  1. Optimistic response creates a temporary comment with current timestamp
  2. RANGE_ADD config automatically adds it to the post's comments list
  3. UI updates instantly—users see their comment immediately
  4. Server processes the request (1-2 seconds)
  5. Real response arrives with actual ID and server timestamp
  6. 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

  1. Relay Official Documentation - Mutations: https://relay.dev/docs/guided-tour/updating-data/graphql-mutations/
    Comprehensive guide with advanced patterns and edge cases

  2. Relay Store API Reference: https://relay.dev/docs/api-reference/store/
    Complete reference for all store methods used in updaters

  3. GraphQL 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! 💻✨