Optimistic Updates
Build responsive UIs that update instantly while background operations complete. command_it supports optimistic updates with two approaches: a simple error listener pattern for learning and straightforward cases, and UndoableCommand for automatic rollback in complex scenarios.
Key Benefits:
- ⚡ Instant UI updates - Update state immediately, sync in background
- 🔄 Graceful error recovery - Restore previous state when operations fail
- 🎯 Choose your approach - Simple manual pattern or automatic UndoableCommand
- 📚 Progressive complexity - Start simple, upgrade when needed
Why Optimistic Updates?
Traditional synchronous updates feel slow:
// ❌ Traditional: User waits for server response
Future<void> toggleBookmark(String postId, bool isBookmarked) async {
// UI shows loading spinner...
await api.updateBookmark(postId, !isBookmarked); // User waits 500ms
// Finally update UI
bookmarkedPosts.value = !isBookmarked;
}Optimistic updates feel instant:
// ✅ Optimistic: UI updates immediately
Future<void> toggleBookmark(String postId, bool isBookmarked) async {
// Save current state in case we need to rollback
final previousState = isBookmarked;
// Update UI immediately - feels instant!
bookmarkedPosts.value = !isBookmarked;
try {
// Sync to server in background
await api.updateBookmark(postId, !isBookmarked);
} catch (e) {
// Rollback on failure
bookmarkedPosts.value = previousState;
showSnackBar('Failed to update bookmark');
}
}Simple Approach with Error Listeners
Before diving into UndoableCommand, let's understand the fundamental pattern. This approach gives you full control and helps you understand what's happening under the hood.
Basic Toggle Pattern
The key insight: when an error occurs, invert the current value to restore the previous state, don't just reload from the server.
This example shows a Post model with an embedded bookmark command:
class Post extends ChangeNotifier {
final String id;
final String title;
bool isBookmarked;
late final toggleBookmarkCommand = Command.createAsyncNoParamNoResult(
() async {
// Optimistic update - toggle immediately
isBookmarked = !isBookmarked;
notifyListeners();
// Sync to server
await getIt<ApiClient>().updateBookmark(id, isBookmarked);
},
)..errors.listen((error, _) {
if (error != null) {
// Restore previous state by inverting the value again
isBookmarked = !isBookmarked;
notifyListeners();
// Show error to user
showSnackBar('Failed to update bookmark: ${error.error}');
}
});
Post(this.id, this.title, this.isBookmarked);
}Why invert instead of reload?
- ✅ No server round-trip needed
- ✅ Preserves other concurrent changes
- ✅ Instant rollback
- ❌️ Requires knowing the inverse operation
Delete Pattern
For deletions, capture the item before removing it. This example uses MapNotifier to store todos by ID:
class TodoManager {
// MapNotifier is a reactive map - widgets watching it will automatically rebuild on any data change
final todos = MapNotifier<String, Todo>();
late final deleteCommand = Command.createAsyncNoResult<Todo>(
(todo) async {
// Optimistic delete
todos.remove(todo.id);
// Sync to server
await getIt<ApiClient>().deleteTodo(todo.id);
},
)..errors.listen((error, _) {
if (error != null) {
// Restore the deleted todo
final todo = error.paramData as Todo;
todos[todo.id] = todo;
showSnackBar('Failed to delete: ${error.error}');
}
});
}Passing the Object
Notice the command accepts Todo as a parameter, not just the ID. This allows the error handler to access the deleted todo via error.paramData for restoration. If you only pass an ID, you'll need to capture the object in a field before deletion (like the _lastDeleted pattern) - in which case UndoableCommand would be a better approach.
When to Use Simple Approach
Good for:
- Learning optimistic updates
- Simple toggles (bookmarks, likes, archived)
- Simple deletions
- When you want explicit control
- Prototyping and understanding the pattern
Limitations:
- Manual error handling for each command
- Need to track previous values for complex state
- More code duplication across commands
- Easy to forget error handling
Advanced: Auto-Rollback with UndoableCommand
For complex state or multiple operations, UndoableCommand automates the pattern above. It captures state before execution and restores it automatically on failure - no manual error handling needed.
Automatic state restoration on failure is enabled by default:
class TodoManager {
// MapNotifier is a reactive map - widgets watching it will automatically rebuild on any data change
final todos = MapNotifier<String, Todo>();
late final deleteTodoCommand = Command.createUndoableNoResult<Todo, Todo>(
(todo, stack) async {
// Capture the todo before deletion
stack.push(todo);
// Make optimistic update
todos.remove(todo.id);
// Try to delete on server
await getIt<ApiClient>().deleteTodo(todo.id);
// If this throws an exception, the undo handler is called automatically
},
undo: (stack, reason) async {
// Restore the deleted todo
final deletedTodo = stack.pop();
todos[deletedTodo.id] = deletedTodo;
},
);
}Execution Flow:
- During execution: Your function runs and calls
stack.push()to save state snapshots - On success: State snapshots remain on the undo stack for potential manual undo
- On failure (automatic by default):
- The
undohandler is called automatically with(stack, reason) - Your undo handler calls
stack.pop()to restore the previous state - Error is still propagated to error handlers
- The
UndoableCommand Patterns
Pattern 1: Toggle State with Immutable Objects
When working with immutable objects, the undo stack automatically preserves the previous state:
class TodoManager {
// MapNotifier is a reactive map - widgets watching it will automatically rebuild on any data change
final todos = MapNotifier<String, Todo>();
late final toggleCompleteCommand =
Command.createUndoableNoResult<String, Todo>(
(id, stack) async {
// Capture the todo before modification
final todo = todos[id]!;
stack.push(todo);
// Optimistic toggle
todos[id] = todo.copyWith(completed: !todo.completed);
// Sync to server
await getIt<ApiClient>().toggleTodo(id, todos[id]!.completed);
},
undo: (stack, reason) async {
// Restore the previous todo state
final previousTodo = stack.pop();
todos[previousTodo.id] = previousTodo;
},
);
}Since Todo is immutable, pushing it to the stack captures a complete snapshot. No need to manually clone - immutability guarantees the saved state won't change.
Pattern 2: Multi-Step Operations
For operations with multiple steps where any failure should rollback everything:
class CheckoutService {
final cart = ValueNotifier<Cart>(Cart.empty());
final order = ValueNotifier<Order?>(null);
late final checkoutCommand =
Command.createUndoableNoParamNoResult<CheckoutState>(
(stack) async {
// Capture state snapshot before execution
stack.push(CheckoutState(cart.value, order.value));
// Step 1: Reserve inventory
final reservation =
await getIt<ApiClient>().reserveInventory(cart.value.items);
// Step 2: Process payment
final payment = await getIt<ApiClient>().processPayment(cart.value.total);
// Step 3: Create order
final newOrder =
await getIt<ApiClient>().createOrder(reservation, payment);
// Update state
order.value = newOrder;
cart.value = Cart.empty();
// If any step fails, all state automatically rolls back
},
undo: (stack, reason) async {
// Restore previous state
final previousState = stack.pop();
cart.value = previousState.cart;
order.value = previousState.order;
},
);
}Manual Undo
UndoableCommand supports manual undo operations by calling the undo() method directly. Disable automatic rollback when you want to control undo manually:
class TextEditorService {
final content = ValueNotifier<String>('');
late final editCommand = Command.createUndoableNoResult<String, String>(
(newText, stack) async {
// Save previous state
stack.push(content.value);
// Update content
content.value = newText;
await getIt<ApiClient>().saveContent(newText);
},
undo: (stack, reason) async {
// Restore previous content
content.value = stack.pop();
},
undoOnExecutionFailure: false, // Disable automatic rollback for manual undo
);
void undo() {
(editCommand as UndoableCommand).undo();
}
}Manual Undo Only
UndoableCommand currently only supports undo, not redo. The undo() method pops the last state from the undo stack and restores it. For redo functionality, you would need to implement your own redo stack.
Choosing an Approach
Both approaches have their place - choose based on your needs and preferences, not dogma.
Use Simple Error Listeners When:
- Learning: You want to understand optimistic updates from first principles
- Simple operations: Single toggles or deletes where the inverse is obvious
- Explicit control: You prefer seeing exactly what happens on error
- Prototyping: Quick experiments before committing to a pattern
- Edge cases: Specific rollback logic that doesn't fit the standard pattern
Use UndoableCommand When:
- Complex state: Multiple fields change together and must roll back atomically
- Consistency: You want the same rollback pattern across all commands
- Less boilerplate: Tired of writing error listeners for every command
- Team projects: Standardize on automatic rollback to prevent forgotten error handling
- Multi-step operations: Complex workflows where any step can fail
Pragmatic Approach
There's no "right" answer - both patterns are valid. Start with the simple approach to understand the mechanics, then upgrade to UndoableCommand when the manual pattern becomes tedious. You can even mix approaches in the same app: use simple listeners for straightforward toggles and UndoableCommand for complex operations.
For deeper context on avoiding dogmatic programming advice, see Thomas Burkhart's article: Understanding the Problems with Dogmatic Programming Advice
When to Use Optimistic Updates
Good candidates for optimistic updates:
- Toggle operations (complete task, like item, follow user)
- Delete operations (remove item, clear notification)
- Simple edits (rename, update single field)
- State changes (mark as read, archive item)
Not recommended for:
- Operations where failure is common (validation errors)
- Complex forms with multiple validation steps
- Operations where the server determines the outcome (approval workflows)
- Financial transactions requiring confirmation
Error Handling
Automatic rollback works with command_it's error handling system:
class TodoManager {
final todos = ValueNotifier<List<Todo>>([]);
late final deleteCommand = Command.createUndoableNoResult<String, List<Todo>>(
(id, stack) async {
// Save state before changes
stack.push(List<Todo>.from(todos.value));
// Optimistic delete
todos.value = todos.value.where((t) => t.id != id).toList();
await getIt<ApiClient>().deleteTodo(id);
// If this throws, state is automatically restored
},
undo: (stack, reason) async {
// Restore previous state
todos.value = stack.pop();
},
)..errors.listen((error, _) {
if (error != null) {
// State already rolled back automatically
// Just show error message
showSnackBar('Failed to delete: ${error.error}');
}
});
}The error is still propagated to error handlers, so you can show appropriate feedback to the user.
See Also
- Command Types - Undoable Commands - All factory methods and API details
- Best Practices - Undoable Commands - More patterns and recommendations
- Error Handling - How errors work with automatic rollback
- Keeping Widgets in Sync with Your Data - Original blog post demonstrating both simple and UndoableCommand patterns