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:
// ❌ 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:
// ✅ 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:
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:
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:
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:
- Durante ejecución: Tu función se ejecuta y llama
stack.push()para guardar snapshots de estado - En éxito: Los snapshots de estado permanecen en el undo stack para potencial undo manual
- En fallo (automático por defecto):
- El handler
undose 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
- El handler
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:
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:
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:
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:
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
- Tipos de Command - Commands Undoable - Todos los métodos factory y detalles de API
- Mejores Prácticas - Commands Undoable - Más patrones y recomendaciones
- Manejo de Errores (Error Handling) - Cómo funcionan los errores con rollback automático
- Keeping Widgets in Sync with Your Data - Post de blog original demostrando ambos patrones simple y UndoableCommand