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

Optimistic Updates Done Right

Creating truthful optimistic responses that match server reality

Optimistic Updates Done Right

Master optimistic UI updates in Relay with free flashcards and spaced repetition practice. This lesson covers the mechanics of optimistic responses, rollback strategies, and synchronization patternsβ€”essential concepts for building responsive React applications that feel instant while maintaining data consistency.

Welcome to Optimistic Updates πŸ’»

Have you ever clicked "Like" on a social media post and watched it instantly turn blue, even before the server responds? That's optimistic updates in actionβ€”a powerful UX pattern where the UI updates immediately based on the expected outcome of a mutation, rather than waiting for server confirmation.

In Relay, optimistic updates let you create applications that feel lightning-fast while still maintaining the integrity of your data flow. But implementing them incorrectly leads to flickering UIs, data inconsistencies, and user confusion. This lesson teaches you how to implement optimistic updates the right way.

Core Concepts: The Optimistic Update Lifecycle πŸ”„

What Makes an Update "Optimistic"?

An optimistic update is when your application assumes a mutation will succeed and immediately updates the UI to reflect that success, before the server responds. Think of it as making an educated guess about the future state of your data.

NORMAL UPDATE FLOW:
User Action β†’ Server Request β†’ Wait... β†’ Server Response β†’ UI Update
                               ↑
                        User sees spinner

OPTIMISTIC UPDATE FLOW:
User Action β†’ UI Update (instant!) β†’ Server Request β†’ Server Response β†’ Confirm/Rollback
              ↑
         User sees immediate feedback

The key principle: The UI shows the intended result immediately, making the app feel instantaneous, while the actual network request happens in the background.

The Three States of Optimistic Updates

Every optimistic update goes through three distinct states:

State Description What's Happening
πŸ”΅ Pending Optimistic data is shown UI displays your guess; request in flight
βœ… Confirmed Server agrees with prediction Real data replaces optimistic data (usually seamless)
❌ Rejected Server disagrees or errors Optimistic data rolled back; error shown

The Relay Optimistic Response Object

In Relay, you provide an optimistic response object that mirrors the shape of your mutation's expected server response:

const [commit] = useMutation(graphql`
  mutation LikePostMutation($postId: ID!) {
    likePost(postId: $postId) {
      post {
        id
        likeCount
        viewerHasLiked
      }
    }
  }
`);

commit({
  variables: { postId: '123' },
  optimisticResponse: {
    likePost: {
      post: {
        id: '123',
        likeCount: currentLikeCount + 1,  // Predicted new count
        viewerHasLiked: true,              // Predicted new state
      },
    },
  },
});

πŸ’‘ Pro tip: The optimistic response structure must exactly match your mutation's return type, including all requested fields.

When to Use Optimistic Updates

βœ… Great candidates for optimistic updates:

  • Like/favorite actions (highly predictable)
  • Follow/unfollow (usually succeeds)
  • Simple toggles (dark mode, notifications)
  • Incrementing counters
  • Adding items to lists where you control the data

❌ Poor candidates for optimistic updates:

  • Payment processing (never assume success!)
  • Complex validation (server might reject)
  • Actions requiring server-generated data (IDs, timestamps)
  • Mutations with unpredictable side effects

🧠 Memory device - LIKE principle:

  • Low-risk operations
  • Immediate feedback needed
  • Known outcome likely
  • Easy to rollback

Implementing Optimistic Updaters πŸ› οΈ

Basic Optimistic Response Pattern

The simplest optimistic update just provides the expected response shape:

import { useMutation, graphql } from 'react-relay';

function ToggleFollowButton({ userId, isFollowing, followerCount }) {
  const [commit, isInFlight] = useMutation(graphql`
    mutation FollowUserMutation($userId: ID!) {
      followUser(userId: $userId) {
        user {
          id
          isFollowing
          followerCount
        }
      }
    }
  `);

  const handleToggle = () => {
    commit({
      variables: { userId },
      optimisticResponse: {
        followUser: {
          user: {
            id: userId,
            isFollowing: !isFollowing,
            followerCount: isFollowing 
              ? followerCount - 1 
              : followerCount + 1,
          },
        },
      },
    });
  };

  return (
    <button onClick={handleToggle}>
      {isFollowing ? 'Unfollow' : 'Follow'}
    </button>
  );
}

What's happening here:

  1. User clicks button
  2. Relay immediately applies the optimistic response to the store
  3. UI re-renders with new values (instant feedback)
  4. Server request fires in background
  5. When response arrives, real data replaces optimistic data

Advanced: Optimistic Updaters for Complex Scenarios

For mutations that affect multiple records or require list manipulation, use optimistic updaters:

const [commitAddComment] = useMutation(graphql`
  mutation AddCommentMutation($postId: ID!, $text: String!) {
    addComment(postId: $postId, text: $text) {
      comment {
        id
        text
        author {
          id
          name
        }
        createdAt
      }
      post {
        id
        commentCount
      }
    }
  }
`);

commitAddComment({
  variables: { postId, text: newComment },
  optimisticResponse: {
    addComment: {
      comment: {
        id: `temp-${Date.now()}`,  // Temporary ID
        text: newComment,
        author: {
          id: currentUserId,
          name: currentUserName,
        },
        createdAt: new Date().toISOString(),
      },
      post: {
        id: postId,
        commentCount: currentCommentCount + 1,
      },
    },
  },
  optimisticUpdater: (store) => {
    const post = store.get(postId);
    const comments = post.getLinkedRecords('comments') || [];
    const newComment = store.getRootField('addComment').getLinkedRecord('comment');
    
    // Add to the end of the list
    post.setLinkedRecords([...comments, newComment], 'comments');
  },
});

Key insight: The optimisticUpdater function lets you manually manipulate the Relay store to handle complex updates like:

  • Adding items to connections
  • Reordering lists
  • Updating multiple related records
  • Creating temporary records with client-generated IDs

⚠️ Critical warning: Temporary IDs (like temp-${Date.now()}) must be replaced by real server IDs when the response arrives. Relay handles this automatically if your mutation returns the new ID.

Handling Rollbacks Gracefully

When an optimistic update fails, Relay automatically rolls back the store to its pre-mutation state. But you should also handle the user experience:

commit({
  variables: { postId },
  optimisticResponse: { /* ... */ },
  onCompleted: (response, errors) => {
    if (errors) {
      // Show error message to user
      toast.error('Failed to like post. Please try again.');
    }
  },
  onError: (error) => {
    // Network error or GraphQL error
    console.error('Mutation error:', error);
    toast.error('Something went wrong. Please check your connection.');
  },
});

πŸ’‘ Best practice: Always provide user feedback for failures. The rollback is automatic, but users need to understand why their action didn't stick.

Example 1: Toggle Action (Simple) πŸ”˜

Let's implement a "bookmark post" feature with optimistic updates:

import { graphql, useMutation } from 'react-relay';
import { useState } from 'react';

function BookmarkButton({ post }) {
  const [commit, isInFlight] = useMutation(graphql`
    mutation BookmarkPostMutation($postId: ID!, $bookmark: Boolean!) {
      updatePostBookmark(postId: $postId, bookmark: $bookmark) {
        post {
          id
          isBookmarked
          bookmarkCount
        }
      }
    }
  `);

  const handleToggle = () => {
    const newBookmarkState = !post.isBookmarked;
    
    commit({
      variables: { 
        postId: post.id, 
        bookmark: newBookmarkState 
      },
      optimisticResponse: {
        updatePostBookmark: {
          post: {
            id: post.id,
            isBookmarked: newBookmarkState,
            bookmarkCount: post.bookmarkCount + (newBookmarkState ? 1 : -1),
          },
        },
      },
    });
  };

  return (
    <button 
      onClick={handleToggle}
      disabled={isInFlight}
      className={post.isBookmarked ? 'bookmarked' : ''}
    >
      {post.isBookmarked ? 'πŸ”– Bookmarked' : 'πŸ”– Bookmark'}
      <span className="count">{post.bookmarkCount}</span>
    </button>
  );
}

Why this works well:

  • βœ… Predictable outcome (toggle is simple)
  • βœ… No server-generated data needed
  • βœ… Easy to calculate new state from current state
  • βœ… Low risk if it fails (just a bookmark)

Flow breakdown:

  1. Current state: isBookmarked: false, bookmarkCount: 42
  2. User clicks β†’ Optimistic response applied instantly
  3. UI shows: isBookmarked: true, bookmarkCount: 43
  4. Server responds with real data (should match)
  5. Seamless confirmation (no visible change)

Example 2: Adding Items to a List πŸ“

Adding a todo item with optimistic creation:

function AddTodoForm({ listId }) {
  const [text, setText] = useState('');
  
  const [commit] = useMutation(graphql`
    mutation AddTodoMutation($listId: ID!, $text: String!) {
      addTodo(listId: $listId, text: $text) {
        todo {
          id
          text
          completed
          createdAt
        }
        list {
          id
          totalCount
        }
      }
    }
  `);

  const handleSubmit = (e) => {
    e.preventDefault();
    if (!text.trim()) return;

    const tempId = `temp-todo-${Date.now()}-${Math.random()}`;
    
    commit({
      variables: { listId, text },
      optimisticResponse: {
        addTodo: {
          todo: {
            id: tempId,
            text: text,
            completed: false,
            createdAt: new Date().toISOString(),
          },
          list: {
            id: listId,
            totalCount: currentTotal + 1,
          },
        },
      },
      optimisticUpdater: (store) => {
        const list = store.get(listId);
        const newTodo = store.getRootField('addTodo').getLinkedRecord('todo');
        
        // Get existing todos connection
        const connection = ConnectionHandler.getConnection(
          list,
          'TodoList_todos'
        );
        
        if (connection) {
          // Create edge for the new todo
          const edge = ConnectionHandler.createEdge(
            store,
            connection,
            newTodo,
            'TodoEdge'
          );
          
          // Insert at the beginning of the list
          ConnectionHandler.insertEdgeBefore(connection, edge);
        }
      },
      onCompleted: () => {
        setText(''); // Clear input on success
      },
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input 
        value={text} 
        onChange={(e) => setText(e.target.value)}
        placeholder="Add a todo..."
      />
      <button type="submit">Add</button>
    </form>
  );
}

Advanced techniques shown:

  • πŸ”‘ Generating temporary client-side IDs
  • πŸ”— Using ConnectionHandler for list manipulation
  • πŸ“ Inserting at specific positions (beginning vs end)
  • 🧹 Clearing form state after successful mutation

Why optimisticUpdater is needed here: Simply providing optimisticResponse isn't enough when adding to a connection. You need to manually insert the new node into the connection's edge list.

Example 3: Conditional Optimism (Intelligent Updates) 🧠

Not all mutations should be optimistic. Here's a pattern for conditional optimistic updates:

function TransferMoneyForm({ fromAccount, toAccount }) {
  const [amount, setAmount] = useState('');
  
  const [commit, isInFlight] = useMutation(graphql`
    mutation TransferMoneyMutation(
      $fromId: ID!
      $toId: ID!
      $amount: Float!
    ) {
      transferMoney(fromId: $fromId, toId: $toId, amount: $amount) {
        fromAccount {
          id
          balance
        }
        toAccount {
          id
          balance
        }
        transaction {
          id
          status
          timestamp
        }
      }
    }
  `);

  const handleTransfer = () => {
    const transferAmount = parseFloat(amount);
    const hasEnoughFunds = fromAccount.balance >= transferAmount;
    const isSmallAmount = transferAmount <= 100;

    commit({
      variables: {
        fromId: fromAccount.id,
        toId: toAccount.id,
        amount: transferAmount,
      },
      // Only use optimistic update for small, safe transfers
      ...(hasEnoughFunds && isSmallAmount && {
        optimisticResponse: {
          transferMoney: {
            fromAccount: {
              id: fromAccount.id,
              balance: fromAccount.balance - transferAmount,
            },
            toAccount: {
              id: toAccount.id,
              balance: toAccount.balance + transferAmount,
            },
            transaction: {
              id: `temp-${Date.now()}`,
              status: 'PENDING',
              timestamp: new Date().toISOString(),
            },
          },
        },
      }),
      onCompleted: (response, errors) => {
        if (errors) {
          toast.error('Transfer failed: ' + errors[0].message);
        } else {
          toast.success('Transfer completed successfully!');
          setAmount('');
        }
      },
    });
  };

  return (
    <div>
      <input 
        type="number" 
        value={amount}
        onChange={(e) => setAmount(e.target.value)}
        placeholder="Amount"
      />
      <button onClick={handleTransfer} disabled={isInFlight}>
        {isInFlight ? 'Processing...' : 'Transfer'}
      </button>
    </div>
  );
}

Decision logic breakdown:

Condition Optimistic? Reasoning
Small amount (<$100) + sufficient funds βœ… Yes Low risk, likely to succeed
Large amount (>$100) ❌ No High stakes, wait for confirmation
Insufficient funds ❌ No Will definitely fail

πŸ’‘ Key insight: Optimistic updates are a choice, not a requirement. Use conditional logic to apply them only when appropriate.

Example 4: Multi-Record Updates with Conflict Resolution πŸ”€

Handling complex scenarios where multiple users might interact with the same data:

function VoteButton({ proposalId, currentVote, voteCount }) {
  const [commit] = useMutation(graphql`
    mutation VoteProposalMutation($proposalId: ID!, $vote: VoteType!) {
      voteProposal(proposalId: $proposalId, vote: $vote) {
        proposal {
          id
          upvotes
          downvotes
          userVote
          score
        }
      }
    }
  `);

  const handleVote = (voteType) => {
    // Calculate optimistic counts
    let optimisticUpvotes = voteCount.upvotes;
    let optimisticDownvotes = voteCount.downvotes;

    // Remove previous vote if exists
    if (currentVote === 'UP') optimisticUpvotes--;
    if (currentVote === 'DOWN') optimisticDownvotes--;

    // Add new vote
    if (voteType === 'UP') optimisticUpvotes++;
    if (voteType === 'DOWN') optimisticDownvotes++;

    const optimisticScore = optimisticUpvotes - optimisticDownvotes;

    commit({
      variables: { proposalId, vote: voteType },
      optimisticResponse: {
        voteProposal: {
          proposal: {
            id: proposalId,
            upvotes: optimisticUpvotes,
            downvotes: optimisticDownvotes,
            userVote: voteType,
            score: optimisticScore,
          },
        },
      },
      onCompleted: (response) => {
        // Server might return different counts if other users voted
        const serverScore = response.voteProposal.proposal.score;
        const optimisticDiff = Math.abs(serverScore - optimisticScore);
        
        if (optimisticDiff > 5) {
          // Significant difference - show notification
          toast.info(`${optimisticDiff} other votes while you were viewing`);
        }
      },
    });
  };

  return (
    <div className="vote-buttons">
      <button onClick={() => handleVote('UP')}>
        ⬆️ {voteCount.upvotes}
      </button>
      <span className="score">{voteCount.upvotes - voteCount.downvotes}</span>
      <button onClick={() => handleVote('DOWN')}>
        ⬇️ {voteCount.downvotes}
      </button>
    </div>
  );
}

Conflict resolution strategy:

  1. Apply optimistic update based on current local state
  2. When server responds, it has the authoritative count
  3. Relay replaces optimistic data with server data (automatic)
  4. Notify user if there's a significant discrepancy

⚠️ Important: The server's response always wins. Never try to "merge" optimistic and server dataβ€”this causes inconsistencies.

Common Mistakes to Avoid ❌

Mistake 1: Forgetting Required Fields

❌ Wrong:

optimisticResponse: {
  likePost: {
    post: {
      id: postId,
      likeCount: currentCount + 1,
      // Missing viewerHasLiked!
    },
  },
}

βœ… Correct:

optimisticResponse: {
  likePost: {
    post: {
      id: postId,
      likeCount: currentCount + 1,
      viewerHasLiked: true,  // All mutation fields must be included
    },
  },
}

Why it matters: Missing fields cause Relay to treat optimistic and server responses as incompatible, leading to unnecessary re-renders.

Mistake 2: Using Optimistic Updates for Unpredictable Operations

❌ Wrong:

// Never be optimistic about payment processing!
optimisticResponse: {
  processPayment: {
    status: 'SUCCESS',  // You can't know this!
    transactionId: 'temp-123',  // Server generates this
  },
}

βœ… Correct:

// Show loading state, wait for server confirmation
commit({
  variables: { amount, cardId },
  // No optimisticResponse at all
  onCompleted: (response) => {
    if (response.processPayment.status === 'SUCCESS') {
      navigate('/payment-success');
    }
  },
});

Mistake 3: Not Handling Rollback UX

❌ Wrong:

commit({
  variables: { postId },
  optimisticResponse: { /* ... */ },
  // No error handling - user has no idea it failed!
});

βœ… Correct:

commit({
  variables: { postId },
  optimisticResponse: { /* ... */ },
  onError: (error) => {
    toast.error('Action failed. Please try again.');
    console.error('Mutation error:', error);
  },
});

Mistake 4: Incorrect Temporary ID Patterns

❌ Wrong:

id: 'temp',  // Not unique! Will collide with other temp records
id: Math.random().toString(),  // Not guaranteed unique

βœ… Correct:

id: `temp-${Date.now()}-${Math.random()}`,  // Unique combination
id: crypto.randomUUID(),  // Modern browsers support this

Mistake 5: Optimistic Updater Doesn't Match Server Behavior

❌ Wrong:

optimisticUpdater: (store) => {
  // Adding to end of list
  const connection = ConnectionHandler.getConnection(...);
  ConnectionHandler.insertEdgeAfter(connection, edge);
}
// But server actually adds to beginning!

βœ… Correct:

optimisticUpdater: (store) => {
  // Match server behavior exactly
  const connection = ConnectionHandler.getConnection(...);
  ConnectionHandler.insertEdgeBefore(connection, edge);  // Adds to beginning
}

Golden rule: Your optimistic updater must perfectly mirror what the server's updater function will do when the real response arrives.

Mistake 6: Over-Engineering Simple Cases

❌ Wrong:

// Simple toggle doesn't need optimisticUpdater
commit({
  variables: { id },
  optimisticResponse: { /* ... */ },
  optimisticUpdater: (store) => {
    const record = store.get(id);
    record.setValue(!record.getValue('isActive'), 'isActive');
    // Unnecessary! optimisticResponse already handles this
  },
});

βœ… Correct:

// optimisticResponse is enough for simple field updates
commit({
  variables: { id },
  optimisticResponse: {
    toggleActive: {
      item: {
        id: id,
        isActive: !currentState,
      },
    },
  },
});

πŸ’‘ Use optimisticUpdater only when:

  • Adding/removing items from connections
  • Updating multiple related records
  • Complex store manipulations
  • Need to read current store state for calculation

Synchronization Patterns πŸ”„

Pattern 1: Optimistic Queue

When users perform multiple rapid actions, queue them properly:

function useLikeQueue() {
  const queueRef = useRef([]);
  const [commit] = useMutation(LikeMutation);

  const like = (postId, currentState) => {
    const action = {
      postId,
      timestamp: Date.now(),
      newState: !currentState,
    };

    queueRef.current.push(action);

    commit({
      variables: { postId },
      optimisticResponse: { /* ... */ },
      onCompleted: () => {
        // Remove from queue when confirmed
        queueRef.current = queueRef.current.filter(
          (a) => a.timestamp !== action.timestamp
        );
      },
    });
  };

  return { like, queueLength: queueRef.current.length };
}

Pattern 2: Optimistic with Retry

Automatically retry failed optimistic mutations:

function useOptimisticMutationWithRetry(mutation, maxRetries = 3) {
  const [commit] = useMutation(mutation);

  const commitWithRetry = (config, retryCount = 0) => {
    commit({
      ...config,
      onError: (error) => {
        if (retryCount < maxRetries) {
          console.log(`Retry attempt ${retryCount + 1}`);
          setTimeout(() => {
            commitWithRetry(config, retryCount + 1);
          }, 1000 * Math.pow(2, retryCount)); // Exponential backoff
        } else {
          // Max retries reached
          config.onError?.(error);
        }
      },
    });
  };

  return commitWithRetry;
}

Pattern 3: Pessimistic Fallback

Start optimistic, but disable it after failures:

function useAdaptiveMutation(mutation) {
  const [useOptimistic, setUseOptimistic] = useState(true);
  const failureCount = useRef(0);
  const [commit] = useMutation(mutation);

  const adaptiveCommit = (config) => {
    commit({
      ...config,
      ...(useOptimistic && { optimisticResponse: config.optimisticResponse }),
      onError: (error) => {
        failureCount.current++;
        
        // Disable optimistic updates after 2 failures
        if (failureCount.current >= 2) {
          setUseOptimistic(false);
          toast.warning('Switched to safe mode due to connection issues');
        }
        
        config.onError?.(error);
      },
      onCompleted: () => {
        // Reset on success
        failureCount.current = 0;
      },
    });
  };

  return [adaptiveCommit, useOptimistic];
}

Testing Optimistic Updates πŸ§ͺ

Testing optimistic behavior requires simulating network delays:

import { MockPayloadGenerator } from 'relay-test-utils';

test('shows optimistic state immediately', async () => {
  const environment = createMockEnvironment();
  
  const { getByText } = render(
    <RelayEnvironmentProvider environment={environment}>
      <LikeButton postId="123" likeCount={10} />
    </RelayEnvironmentProvider>
  );

  // Click the button
  fireEvent.click(getByText('Like'));

  // Optimistic update should show immediately
  expect(getByText('11')).toBeInTheDocument();
  expect(getByText('Unlike')).toBeInTheDocument();

  // Resolve the mutation
  await act(async () => {
    environment.mock.resolveMostRecentOperation((operation) =>
      MockPayloadGenerator.generate(operation, {
        LikePostPayload: () => ({
          post: {
            id: '123',
            likeCount: 11,
            viewerHasLiked: true,
          },
        }),
      })
    );
  });

  // Should still show correct state after confirmation
  expect(getByText('11')).toBeInTheDocument();
});

test('rolls back on error', async () => {
  const environment = createMockEnvironment();
  
  const { getByText } = render(
    <RelayEnvironmentProvider environment={environment}>
      <LikeButton postId="123" likeCount={10} />
    </RelayEnvironmentProvider>
  );

  fireEvent.click(getByText('Like'));
  expect(getByText('11')).toBeInTheDocument();

  // Reject the mutation
  await act(async () => {
    environment.mock.rejectMostRecentOperation(
      new Error('Network error')
    );
  });

  // Should rollback to original state
  expect(getByText('10')).toBeInTheDocument();
  expect(getByText('Like')).toBeInTheDocument();
});

Performance Considerations ⚑

Minimizing Optimistic Update Overhead

1. Only include necessary fields:

// ❌ Wasteful - including fields that won't change
optimisticResponse: {
  updatePost: {
    post: {
      id, title, content, author, createdAt, updatedAt, tags, comments...
    },
  },
}

// βœ… Efficient - only changed fields
optimisticResponse: {
  updatePost: {
    post: {
      id,
      title: newTitle,  // Only what changed
    },
  },
}

2. Avoid deep object copying:

// ❌ Slow - deep cloning entire objects
optimisticResponse: JSON.parse(JSON.stringify(originalResponse))

// βœ… Fast - only specify changed values
optimisticResponse: {
  likePost: {
    post: {
      id: postId,
      likeCount: currentCount + 1,
    },
  },
}

3. Debounce rapid mutations:

import { useDebouncedCallback } from 'use-debounce';

const debouncedCommit = useDebouncedCallback(
  (variables) => {
    commit({ variables, optimisticResponse: { /* ... */ } });
  },
  300  // Wait 300ms after last call
);

Key Takeaways 🎯

πŸ“‹ Quick Reference Card

Optimistic Response Provide expected mutation result shape; Relay applies immediately
Optimistic Updater Manually manipulate store for complex updates (lists, connections)
Automatic Rollback Relay reverts store to pre-mutation state on error
Server Wins Real server data always replaces optimistic predictions
Temporary IDs Use unique client-generated IDs for new records
Error Handling Always show user feedback via onError/onCompleted callbacks
Conditional Optimism Only use optimistic updates for low-risk, predictable operations
Match Server Logic Optimistic updater must mirror server-side behavior exactly

When to use optimistic updates:

  • βœ… Like/favorite actions
  • βœ… Simple toggles
  • βœ… Adding items you control
  • ❌ Payment processing
  • ❌ Complex validation
  • ❌ Server-generated data

Golden Rules:

  1. Make it feel instant (that's the point!)
  2. Always handle errors gracefully
  3. Keep optimistic logic simple
  4. Test rollback scenarios
  5. Don't optimize what users won't notice

πŸ“š Further Study

  1. Relay Official Docs - Optimistic Updates: https://relay.dev/docs/guided-tour/updating-data/graphql-mutations/#optimistic-updates
  2. Relay Resolver Patterns: https://relay.dev/docs/guides/relay-resolvers/
  3. React Concurrent Mode and Suspense with Relay: https://relay.dev/docs/guided-tour/rendering/loading-states/

πŸŽ‰ Congratulations! You now understand how to implement optimistic updates correctly in Relay. You've learned when to use them, how to handle complex scenarios with optimistic updaters, and most importantlyβ€”when not to be optimistic. Practice these patterns in your own applications, starting with simple toggles before moving to complex list manipulations. Remember: optimistic updates are about perceived performance, not actual speed. Used wisely, they make your app feel magical. Used carelessly, they create confusion and bugs. Always prioritize correctness over perceived speed when in doubt! πŸ’ͺ