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:
- User clicks button
- Relay immediately applies the optimistic response to the store
- UI re-renders with new values (instant feedback)
- Server request fires in background
- 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:
- Current state:
isBookmarked: false,bookmarkCount: 42 - User clicks β Optimistic response applied instantly
- UI shows:
isBookmarked: true,bookmarkCount: 43 - Server responds with real data (should match)
- 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
ConnectionHandlerfor 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:
- Apply optimistic update based on current local state
- When server responds, it has the authoritative count
- Relay replaces optimistic data with server data (automatic)
- 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:
- Make it feel instant (that's the point!)
- Always handle errors gracefully
- Keep optimistic logic simple
- Test rollback scenarios
- Don't optimize what users won't notice
π Further Study
- Relay Official Docs - Optimistic Updates: https://relay.dev/docs/guided-tour/updating-data/graphql-mutations/#optimistic-updates
- Relay Resolver Patterns: https://relay.dev/docs/guides/relay-resolvers/
- 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! πͺ