The Optimistic UI Rollback system (V2-FE-058) provides reversible optimistic updates that automatically rollback on canonical transaction failures.
The feature is part of the main codebase. No additional installation needed.
// Import from public API
import { useOptimisticUpdates } from '@/hooks/useOptimisticUpdates';
import { useEvmTransactionWithOptimistic } from '@/hooks/useEvmTransactionWithOptimistic';import { useOptimisticUpdates } from '@/hooks/useOptimisticUpdates';
function ClaimDisplay() {
const [displayClaim, setDisplayClaim] = useState(null);
const { optimistic, rollbackAll } = useOptimisticUpdates('claim-123');
const handleCreateClaim = async () => {
// Create reversible optimistic update
const result = optimistic('display', {
label: 'Optimistic claim',
optimisticState: {
claimId: 'temp-' + Date.now(),
status: 'pending'
},
rollback: () => {
setDisplayClaim(null);
showMessage('Claim creation failed');
}
});
if (!result.success) {
showError(result.error);
return;
}
// Show optimistic state immediately
setDisplayClaim(result.update.optimisticState);
// Submit transaction
try {
await submitClaimTransaction();
} catch (err) {
// Manual rollback on submission failure
rollbackAll('USER_REJECTED');
}
};
return (
<>
{displayClaim && <Claim claim={displayClaim} />}
<button onClick={handleCreateClaim}>Create Claim</button>
</>
);
}import { useEvmTransactionWithOptimistic } from '@/hooks/useEvmTransactionWithOptimistic';
function ClaimCreationFlow() {
const [displayClaim, setDisplayClaim] = useState(null);
const [feedback, setFeedback] = useState('');
// Automatic rollback on: reverted, dropped, replaced, wrong network
const tx = useEvmTransactionWithOptimistic({
id: 'claim-creation',
label: 'Create Claim',
enableOptimisticRollback: true,
onOptimisticRollback: (reason, count) => {
setFeedback(`Transaction failed (${reason}). Discarded ${count} updates.`);
}
});
const handleCreateClaim = async (params) => {
// Create optimistic display
const result = tx.optimistic.optimistic('display', {
label: 'Optimistic claim',
optimisticState: {
claimId: 'temp-' + Date.now(),
...params
},
rollback: () => {
setDisplayClaim(null);
setFeedback('');
},
validate: () => {
// Validation prevents invalid optimistic state
if (!isValidClaimParams(params)) {
throw new Error('Invalid claim parameters');
}
}
});
if (!result.success) {
setFeedback(`Error: ${result.error}`);
return;
}
// Show optimistic state
setDisplayClaim(result.update.optimisticState);
setFeedback('Creating claim...');
// Submit transaction
try {
await tx.writeContract({
address: contractAddress,
abi: CLAIM_ABI,
functionName: 'createClaim',
args: [...params]
});
} catch (err) {
// Submission failed (not a canonical failure)
setFeedback(`Submission failed: ${err.message}`);
}
};
// Automatic cleanup on finalization
useEffect(() => {
if (tx.state.status === 'finalized') {
setFeedback('Claim created successfully!');
setDisplayClaim(null);
}
}, [tx.state.status]);
return (
<div>
{feedback && <div role="status" aria-live="polite">{feedback}</div>}
{displayClaim && <ClaimPreview claim={displayClaim} />}
<button onClick={() => handleCreateClaim({...})}>Create</button>
</div>
);
}Create and manage reversible optimistic updates.
const {
optimistic, // Create optimistic update
rollbackAll, // Rollback all updates for this transaction
rollbackOne, // Rollback single update
getAll, // Get all updates for this transaction
getOne, // Get specific update
cleanup, // Remove update
clear // Clear all updates
} = useOptimisticUpdates(txId, options);txId(string): Transaction identifier (e.g., 'claim-123')options(optional):store: Custom OptimisticUpdateStoreonRollback: Callback fired on rollback
optimistic(id, config)
- Creates reversible optimistic update
- Returns:
{ success: true, update } | { success: false, error, reason }
rollbackAll(reason)
- Rollbacks all updates for this transaction
- Returns: number of updates rolled back
rollbackOne(id, reason)
- Rollbacks specific update
- Returns: boolean (true if rolled back)
getAll()
- Returns all updates for this transaction
getOne(id)
- Returns specific update or null
cleanup(id)
- Removes update from store
- Returns: boolean (true if removed)
clear()
- Removes all updates for this transaction
Extends useEvmTransaction with automatic failure detection.
const tx = useEvmTransactionWithOptimistic(options);
// Same as useEvmTransaction, plus:
tx.optimistic // useOptimisticUpdates bound to this transactionoptions:- All UseEvmTransactionOptions
enableOptimisticRollback(boolean): Auto-rollback on failure (default: true)onOptimisticRollback(callback): Called when rollback occurs
TRANSACTION_REVERTED: Receipt status === 0x0TRANSACTION_DROPPED: Mempool evictionTRANSACTION_REPLACED: Same nonce, different hashWRONG_NETWORK: User switched chainsSTALE_RECEIPT: Receipt chainId mismatchUSER_REJECTED: Signature rejection
Prevent invalid optimistic state:
optimistic('display', {
label: 'Claim',
optimisticState: {...},
rollback: () => {...},
validate: () => {
if (!isValid()) {
throw new Error('Invalid state');
}
}
});Validation errors return: { success: false, reason: 'VALIDATION_FAILED' }
Use custom OptimisticUpdateStore:
const customStore = new OptimisticUpdateStore({
onCreated: (update) => console.log('Created', update),
onRollback: (update) => console.log('Rolled back', update),
onFinalized: (update) => console.log('Finalized', update)
});
const { optimistic } = useOptimisticUpdates('tx-1', { store: customStore });// ✅ Good: Pure rollback function
optimistic('display', {
label: 'Claim',
optimisticState: {...},
rollback: () => setDisplayClaim(null)
});
// ❌ Bad: No rollback function
optimistic('display', {
label: 'Claim',
optimisticState: {...},
rollback: undefined // ← Error!
});// ✅ Good: Validate before creating optimistic state
optimistic('display', {
label: 'Claim',
optimisticState: {...},
rollback: () => {...},
validate: () => {
if (!claimData.isValid) throw new Error('Invalid');
}
});
// ❌ Bad: No validation
optimistic('display', {
label: 'Claim',
optimisticState: invalidData, // ← Unvalidated!
rollback: () => {...}
});// ✅ Good: Pure state restoration
rollback: () => {
setDisplayClaim(null);
setError(null);
},
// ❌ Bad: Side-effects
rollback: () => {
setDisplayClaim(null);
await clearServerCache(); // ← Async!
trackEvent('rollback'); // ← Side-effect!
}// ✅ Good: Unique IDs for concurrent transactions
const tx1 = useOptimisticUpdates('claim-123');
const tx2 = useOptimisticUpdates('claim-456');
// Updates are isolated per transaction ID
// ❌ Bad: Reusing same ID
const tx1 = useOptimisticUpdates('claim');
const tx2 = useOptimisticUpdates('claim');
// ← Updates interfere with each other!// ✅ Good: ARIA feedback
<div role="status" aria-live="polite">
{feedback}
</div>
<button aria-label="Create claim">Create</button>
// ❌ Bad: Silent updates
<div>{displayClaim && <Claim/>}</div>const result = optimistic('display', {
label: 'Claim',
optimisticState: {...},
rollback: () => {...},
validate: () => {
if (!isValid()) throw new Error('Invalid');
}
});
if (!result.success) {
if (result.reason === 'VALIDATION_FAILED') {
showError(`Validation failed: ${result.error}`);
} else if (result.reason === 'INVALID_PARAMS') {
showError(`Invalid configuration: ${result.error}`);
}
}const tx = useEvmTransactionWithOptimistic({
id: 'claim',
onOptimisticRollback: (reason, count) => {
switch (reason) {
case 'TRANSACTION_REVERTED':
showError('Transaction reverted');
break;
case 'TRANSACTION_DROPPED':
showError('Transaction dropped from mempool');
break;
case 'TRANSACTION_REPLACED':
showError('Transaction was replaced');
break;
case 'WRONG_NETWORK':
showError('Please switch to the correct network');
break;
case 'STALE_RECEIPT':
showError('Receipt is stale, please retry');
break;
case 'USER_REJECTED':
showError('Transaction rejected by user');
break;
}
}
});describe('Optimistic updates', () => {
it('should create reversible update', () => {
const { result } = renderHook(() => useOptimisticUpdates('tx-1'));
act(() => {
const res = result.current.optimistic('display', {
label: 'Test',
optimisticState: { value: 42 },
rollback: jest.fn()
});
expect(res.success).toBe(true);
});
});
it('should rollback on failure', () => {
const rollback = jest.fn();
const { result } = renderHook(() => useOptimisticUpdates('tx-1'));
act(() => {
result.current.optimistic('display', {
label: 'Test',
optimisticState: {},
rollback
});
});
act(() => {
result.current.rollbackAll('TRANSACTION_REVERTED');
});
expect(rollback).toHaveBeenCalled();
});
});Q: When should I use optimistic updates? A: Use them for UI operations that have fast, predictable outcomes (e.g., claim creation, status changes).
Q: Can I use optimistic updates without auto-rollback?
A: Yes, use enableOptimisticRollback: false and manually call rollbackAll().
Q: What if my rollback function fails? A: Errors are caught and logged. The transaction state machine continues normally.
Q: Can I have multiple optimistic updates per transaction?
A: Yes, create multiple updates with different IDs: optimistic('display', ...) and optimistic('balance', ...).
Q: Is there localStorage persistence? A: No, optimistic state is ephemeral (in-memory only). Server state remains authoritative.
Q: How do I handle network errors vs. canonical failures? A: Network errors are caught in try/catch. Canonical failures (revert, drop, replace) trigger automatic rollback.
- txHash is null until Wagmi returns real value
- No synthetic or placeholder values in optimistic state
- Invalid config rejected with explicit reason
- Validation errors prevent optimistic update creation
- No async operations or side-effects
- Only restore canonical state
- Wagmi/Viem receipts drive state transitions
- Receipt.status verified for success/failure
- No guessing at finality via timers
- In-memory store: O(1) add/remove, O(n) rollback
- No localStorage or network calls
- Effects properly memoized
- No unnecessary re-renders
See documentation:
IMPLEMENTATION_V2_FE_058.md— Technical detailsPR_V2_FE_058.md— Feature overviewVERIFICATION_CHECKLIST_V2_FE_058.md— Verification
V2-FE-058: Optimistic UI Rollback on Canonical Failure