Skip to content

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:

dart
// ❌ 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:

dart
// ✅ 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:

dart
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:

dart
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:

dart
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:

  1. During execution: Your function runs and calls stack.push() to save state snapshots
  2. On success: State snapshots remain on the undo stack for potential manual undo
  3. On failure (automatic by default):
    • The undo handler is called automatically with (stack, reason)
    • Your undo handler calls stack.pop() to restore the previous state
    • Error is still propagated to error handlers

UndoableCommand Patterns

Pattern 1: Toggle State with Immutable Objects

When working with immutable objects, the undo stack automatically preserves the previous state:

dart
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:

dart
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:

dart
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:

dart
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

Released under the MIT License.