Skip to content

Actualizaciones Optimistas

Construye UIs responsivas que se actualizan instantáneamente mientras las operaciones en background se completan. command_it soporta actualizaciones optimistas con dos enfoques: un patrón simple de listener de errores para aprender y casos directos, y UndoableCommand para rollback automático en escenarios complejos.

Beneficios Clave:

  • Actualizaciones de UI instantáneas - Actualiza el estado inmediatamente, sincroniza en background
  • 🔄 Recuperación de errores elegante - Restaura estado anterior cuando las operaciones fallan
  • 🎯 Elige tu enfoque - Patrón manual simple o UndoableCommand automático
  • 📚 Complejidad progresiva - Empieza simple, mejora cuando lo necesites

¿Por Qué Actualizaciones Optimistas?

Las actualizaciones síncronas tradicionales se sienten lentas:

dart
// ❌ Tradicional: El usuario espera la respuesta del servidor
Future<void> toggleBookmark(String postId, bool isBookmarked) async {
  // UI muestra spinner de carga...
  await api.updateBookmark(postId, !isBookmarked); // El usuario espera 500ms
  // Finalmente actualiza UI
  bookmarkedPosts.value = !isBookmarked;
}

Las actualizaciones optimistas se sienten instantáneas:

dart
// ✅ Optimista: La UI se actualiza inmediatamente
Future<void> toggleBookmark(String postId, bool isBookmarked) async {
  // Guarda estado actual en caso de que necesitemos rollback
  final previousState = isBookmarked;

  // ¡Actualiza UI inmediatamente - se siente instantáneo!
  bookmarkedPosts.value = !isBookmarked;

  try {
    // Sincroniza con servidor en background
    await api.updateBookmark(postId, !isBookmarked);
  } catch (e) {
    // Rollback en fallo
    bookmarkedPosts.value = previousState;
    showSnackBar('Error al actualizar marcador');
  }
}

Enfoque Simple con Listeners de Error

Antes de sumergirnos en UndoableCommand, entendamos el patrón fundamental. Este enfoque te da control total y te ayuda a entender qué está pasando internamente.

Patrón Básico de Toggle

La idea clave: cuando ocurre un error, invierte el valor actual para restaurar el estado anterior, no simplemente recargues del servidor.

Este ejemplo muestra un modelo Post con un command de marcador embebido:

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);
}

¿Por qué invertir en lugar de recargar?

  • ✅ No se necesita round-trip al servidor
  • ✅ Preserva otros cambios concurrentes
  • ✅ Rollback instantáneo
  • ❌️ Requiere conocer la operación inversa

Patrón de Eliminación

Para eliminaciones, captura el item antes de eliminarlo. Este ejemplo usa MapNotifier para almacenar todos por 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}');
      }
    });
}

Pasando el Objeto

Nota que el command acepta Todo como parámetro, no solo el ID. Esto permite que el handler de error acceda al todo eliminado via error.paramData para restauración. Si solo pasas un ID, necesitarás capturar el objeto en un campo antes de la eliminación (como el patrón _lastDeleted) - en cuyo caso UndoableCommand sería un mejor enfoque.

Cuándo Usar el Enfoque Simple

Bueno para:

  • Aprender actualizaciones optimistas
  • Toggles simples (marcadores, likes, archivados)
  • Eliminaciones simples
  • Cuando quieres control explícito
  • Prototipado y entender el patrón

Limitaciones:

  • Manejo de errores manual para cada command
  • Necesitas trackear valores anteriores para estado complejo
  • Más duplicación de código entre commands
  • Fácil olvidar manejo de errores

Avanzado: Auto-Rollback con UndoableCommand

Para estado complejo o múltiples operaciones, UndoableCommand automatiza el patrón de arriba. Captura estado antes de la ejecución y lo restaura automáticamente en fallo - no se necesita manejo de errores manual.

La restauración automática de estado en fallo está habilitada por defecto:

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;
    },
  );
}

Flujo de Ejecución:

  1. Durante ejecución: Tu función se ejecuta y llama stack.push() para guardar snapshots de estado
  2. En éxito: Los snapshots de estado permanecen en el undo stack para potencial undo manual
  3. En fallo (automático por defecto):
    • El handler undo se llama automáticamente con (stack, reason)
    • Tu handler de undo llama stack.pop() para restaurar el estado anterior
    • El error aún se propaga a los handlers de error

Patrones de UndoableCommand

Patrón 1: Toggle de Estado con Objetos Inmutables

Cuando trabajas con objetos inmutables, el undo stack automáticamente preserva el estado anterior:

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;
    },
  );
}

Como Todo es inmutable, hacer push al stack captura un snapshot completo. No necesitas clonar manualmente - la inmutabilidad garantiza que el estado guardado no cambiará.

Patrón 2: Operaciones Multi-Paso

Para operaciones con múltiples pasos donde cualquier fallo debe hacer rollback de todo:

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;
    },
  );
}

Undo Manual

UndoableCommand soporta operaciones de undo manual llamando al método undo() directamente. Deshabilita el rollback automático cuando quieras controlar el undo manualmente:

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();
  }
}

Solo Undo Manual

UndoableCommand actualmente solo soporta undo, no redo. El método undo() hace pop del último estado del undo stack y lo restaura. Para funcionalidad de redo, necesitarías implementar tu propio redo stack.

Eligiendo un Enfoque

Ambos enfoques tienen su lugar - elige basándote en tus necesidades y preferencias, no en dogma.

Usa Listeners de Error Simples Cuando:

  • Aprendiendo: Quieres entender actualizaciones optimistas desde los principios básicos
  • Operaciones simples: Toggles o eliminaciones simples donde el inverso es obvio
  • Control explícito: Prefieres ver exactamente qué pasa en error
  • Prototipado: Experimentos rápidos antes de comprometerte con un patrón
  • Casos edge: Lógica de rollback específica que no encaja en el patrón estándar

Usa UndoableCommand Cuando:

  • Estado complejo: Múltiples campos cambian juntos y deben hacer rollback atómicamente
  • Consistencia: Quieres el mismo patrón de rollback en todos los commands
  • Menos boilerplate: Cansado de escribir listeners de error para cada command
  • Proyectos de equipo: Estandarizar en rollback automático para prevenir manejo de errores olvidado
  • Operaciones multi-paso: Flujos de trabajo complejos donde cualquier paso puede fallar

Enfoque Pragmático

No hay respuesta "correcta" - ambos patrones son válidos. Empieza con el enfoque simple para entender la mecánica, luego mejora a UndoableCommand cuando el patrón manual se vuelva tedioso. Incluso puedes mezclar enfoques en la misma app: usa listeners simples para toggles directos y UndoableCommand para operaciones complejas.

Para contexto más profundo sobre evitar consejos de programación dogmáticos, ver el artículo de Thomas Burkhart: Understanding the Problems with Dogmatic Programming Advice

Cuándo Usar Actualizaciones Optimistas

Buenos candidatos para actualizaciones optimistas:

  • Operaciones de toggle (completar tarea, like a item, seguir usuario)
  • Operaciones de eliminación (remover item, limpiar notificación)
  • Ediciones simples (renombrar, actualizar campo único)
  • Cambios de estado (marcar como leído, archivar item)

No recomendado para:

  • Operaciones donde el fallo es común (errores de validación)
  • Formularios complejos con múltiples pasos de validación
  • Operaciones donde el servidor determina el resultado (flujos de aprobación)
  • Transacciones financieras que requieren confirmación

Manejo de Errores (Error Handling)

El rollback automático funciona con el sistema de manejo de errores de command_it:

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}');
      }
    });
}

El error aún se propaga a los handlers de error, así que puedes mostrar feedback apropiado al usuario.

Ver También

Publicado bajo la Licencia MIT.