Observando Commands con watch_it
Una de las combinaciones más poderosas en el ecosistema flutter_it es usar watch_it para observar commands de command_it. Los commands son objetos ValueListenable que exponen su estado (isRunning, value, errors) como propiedades ValueListenable, haciéndolos naturalmente observables por watch_it. Este patrón proporciona gestión de estado reactiva y declarativa para operaciones async con estados de carga automáticos, manejo de errores y actualizaciones de resultado.
Aprende Sobre Commands Primero
Si eres nuevo en command_it, empieza con la guía command_it Getting Started para entender cómo funcionan los commands.
¿Por Qué watch_it + command_it?
Los commands encapsulan operaciones async y rastrean su estado de ejecución (isRunning, value, errors). watch_it permite que tus widgets se reconstruyan reactivamente cuando estos estados cambian, creando una experiencia de usuario fluida sin gestión de estado manual.
Beneficios:
- Estados de carga automáticos - No necesitas rastrear manualmente booleanos
isLoading - Resultados reactivos - La UI se actualiza automáticamente cuando el command se completa
- Manejo de errores incorporado - Los commands rastrean errores,
watch_itlos muestra - Separación limpia - Lógica de negocio en commands, lógica de UI en widgets
- Sin boilerplate - Sin
setState, sinStreamBuilder, sin listeners manuales
Observando un Command
Un patrón típico es observar tanto el resultado del command como su estado de ejecución como valores separados:
class TodoLoadingWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
// Load data on first build
callOnce((_) {
di<TodoManager>().fetchTodosCommand.run();
});
// Watch command's isRunning property to show loading state
final isLoading =
watchValue((TodoManager m) => m.fetchTodosCommand.isRunning);
// Watch the command itself to get its value (List<TodoModel>)
// Commands are ValueListenables, so watching them gives you their current value
final todos = watchValue((TodoManager m) => m.fetchTodosCommand);
return Scaffold(
appBar: AppBar(title: const Text('Watch Command - Loading State')),
body: Column(
children: [
// Show loading indicator when command is executing
if (isLoading)
const LinearProgressIndicator()
else
const SizedBox(height: 4),
Expanded(
child: isLoading && todos.isEmpty
? const Center(child: CircularProgressIndicator())
: todos.isEmpty
? const Center(child: Text('No todos'))
: ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return ListTile(
title: Text(todo.title),
subtitle: Text(todo.description),
);
},
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: ElevatedButton(
// Disable button while loading
onPressed: isLoading
? null
: () => di<TodoManager>().fetchTodosCommand.run(),
child: const Text('Refresh'),
),
),
],
),
);
}
}Puntos clave:
- Observa el command mismo para obtener su valor (el resultado)
- Observa
command.isRunningpara obtener el estado de ejecución - El widget se reconstruye automáticamente cuando cualquiera cambia
- Los commands son objetos
ValueListenable, por lo que funcionan perfectamente conwatch_it - El botón se deshabilita durante la ejecución
- El indicador de progreso se muestra mientras carga
Observando Errores de Command
Muestra errores observando la propiedad errors del command:
class CommandErrorWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
callOnce((_) {
di<TodoManager>().fetchTodosCommand.run();
});
// Watch command's errors property to display error messages
final error = watchValue((TodoManager m) => m.fetchTodosCommand.errors);
final isLoading =
watchValue((TodoManager m) => m.fetchTodosCommand.isRunning);
final todos = watchValue((TodoManager m) => m.fetchTodosCommand);
return Scaffold(
appBar: AppBar(title: const Text('Watch Command - Errors')),
body: Column(
children: [
// Display error banner when command fails
if (error != null)
Container(
color: Colors.red.shade100,
padding: const EdgeInsets.all(16),
child: Row(
children: [
const Icon(Icons.error, color: Colors.red),
const SizedBox(width: 8),
Expanded(
child: Text(
'Error: ${error.toString()}',
style: const TextStyle(color: Colors.red),
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
// Clear error by executing again
di<TodoManager>().fetchTodosCommand.run();
},
),
],
),
),
if (isLoading)
const LinearProgressIndicator()
else
const SizedBox(height: 4),
Expanded(
child: todos.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (error != null) ...[
const Icon(Icons.error_outline,
size: 64, color: Colors.red),
const SizedBox(height: 16),
const Text('Failed to load todos'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () =>
di<TodoManager>().fetchTodosCommand.run(),
child: const Text('Retry'),
),
] else if (isLoading)
const CircularProgressIndicator()
else
const Text('No todos'),
],
),
)
: ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return ListTile(
title: Text(todo.title),
subtitle: Text(todo.description),
);
},
),
),
],
),
);
}
}Patrones de manejo de errores:
- Mostrar banner de error en la parte superior de la pantalla
- Mostrar mensaje de error inline
- Proporcionar botón de reintentar
- Limpiar errores al reintentar
Usar Handlers para Efectos Secundarios
Mientras watch es para reconstruir UI, usa registerHandler para efectos secundarios como navegación o mostrar toasts:
Handler de Éxito
class CreateTodoWithHandlerWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
final titleController = createOnce(() => TextEditingController());
final descController = createOnce(() => TextEditingController());
// Use registerHandler to handle successful command completion
// This is perfect for navigation, showing success messages, etc.
registerHandler(
select: (TodoManager m) => m.createTodoCommand,
handler: (context, result, _) {
// Show success snackbar
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Created: ${result!.title}'),
backgroundColor: Colors.green,
),
);
// Navigate back with result
Navigator.of(context).pop(result);
},
);
final isCreating =
watchValue((TodoManager m) => m.createTodoCommand.isRunning);
return Scaffold(
appBar: AppBar(title: const Text('Command Handler - Success')),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
const Text(
'This example uses registerHandler to navigate on success',
style: TextStyle(fontStyle: FontStyle.italic),
),
const SizedBox(height: 24),
TextField(
controller: titleController,
decoration: const InputDecoration(
labelText: 'Title',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextField(
controller: descController,
decoration: const InputDecoration(
labelText: 'Description',
border: OutlineInputBorder(),
),
maxLines: 3,
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: isCreating
? null
: () {
final params = CreateTodoParams(
title: titleController.text,
description: descController.text,
);
di<TodoManager>().createTodoCommand.run(params);
},
child: isCreating
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Create Todo'),
),
),
],
),
),
);
}
}Efectos secundarios comunes de éxito:
- Navegar a otra pantalla
- Mostrar snackbar/toast de éxito
- Disparar otro command
- Registrar evento de analytics
Handler de Error
class CommandErrorHandlerWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
callOnce((_) {
di<TodoManager>().fetchTodosCommand.run();
});
// Use registerHandler to handle command errors
// Shows error dialog or snackbar when command fails
registerHandler(
select: (TodoManager m) => m.fetchTodosCommand.errors,
handler: (context, error, _) {
// Show error dialog
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Error'),
content: Text(error.toString()),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
di<TodoManager>().fetchTodosCommand.run();
},
child: const Text('Retry'),
),
],
),
);
},
);
final isLoading =
watchValue((TodoManager m) => m.fetchTodosCommand.isRunning);
final todos = watchValue((TodoManager m) => m.fetchTodosCommand);
return Scaffold(
appBar: AppBar(title: const Text('Command Handler - Errors')),
body: Column(
children: [
const Padding(
padding: EdgeInsets.all(16.0),
child: Text(
'This example uses registerHandler to show error dialogs',
style: TextStyle(fontStyle: FontStyle.italic),
),
),
if (isLoading) const LinearProgressIndicator(),
Expanded(
child: isLoading && todos.isEmpty
? const Center(child: CircularProgressIndicator())
: todos.isEmpty
? const Center(child: Text('No todos'))
: ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return ListTile(
title: Text(todo.title),
subtitle: Text(todo.description),
);
},
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: ElevatedButton(
onPressed: isLoading
? null
: () => di<TodoManager>().fetchTodosCommand.run(),
child: const Text('Refresh'),
),
),
],
),
);
}
}Efectos secundarios comunes de error:
- Mostrar diálogo de error
- Mostrar snackbar de error
- Registrar error en reporte de crashes
- Lógica de reintentar
Observando Resultados de Command
La propiedad results proporciona un objeto CommandResult conteniendo todo el estado del command en un lugar:
class CommandResultsWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
callOnce((_) => di<TodoManager>().fetchTodosCommand.run());
// Watch the command's results property which contains all state:
// - data: The command's value
// - isRunning: Execution state
// - hasError: Whether an error occurred
// - error: The error if any
return watchValue(
(TodoManager m) => m.fetchTodosCommand.results,
).toWidget(
onData: (todos, param) => ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) => ListTile(
title: Text(todos[index].title),
subtitle: Text(todos[index].description),
),
),
onError: (error, lastResult, param) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text('Error: $error'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => di<TodoManager>().fetchTodosCommand.run(),
child: const Text('Retry'),
),
],
),
),
whileRunning: (lastResult, param) => const Center(
child: CircularProgressIndicator(),
),
);
}
}CommandResult contiene:
data- El valor actual del commandisRunning- Si el command se está ejecutandohasError- Si ocurrió un errorerror- El objeto de error si hay algunoisSuccess- Si la ejecución tuvo éxito (!isRunning && !hasError)
La extensión .toWidget():
onData- Construir UI cuando los datos estén disponiblesonError- Construir UI cuando ocurre un error (muestra último resultado exitoso si está disponible)whileRunning- Construir UI mientras el command se está ejecutando
Este patrón es ideal cuando necesitas manejar todos los estados del command de forma declarativa.
Otras Propiedades de Command
También puedes observar otras propiedades del command individualmente:
command.isRunning- Estado de ejecucióncommand.errors- Notificaciones de errorcommand.canRun- Si el command puede ejecutarse actualmente (combina!isRunning && !restriction)
Encadenar Commands
Usa handlers para encadenar commands juntos:
class CommandChainingWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
callOnce((_) {
di<TodoManager>().fetchTodosCommand.run();
});
// Use registerHandler to chain commands
// When create succeeds, automatically refresh the list
registerHandler(
select: (TodoManager m) => m.createTodoCommand,
handler: (context, result, _) {
// Chain: after creating, fetch the updated list
di<TodoManager>().fetchTodosCommand.run();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Created "${result!.title}" and refreshed list'),
backgroundColor: Colors.green,
),
);
},
);
final isCreating =
watchValue((TodoManager m) => m.createTodoCommand.isRunning);
final isFetching =
watchValue((TodoManager m) => m.fetchTodosCommand.isRunning);
final todos = watchValue((TodoManager m) => m.fetchTodosCommand);
return Scaffold(
appBar: AppBar(title: const Text('Command Chaining')),
body: Column(
children: [
Container(
padding: const EdgeInsets.all(16),
color: Colors.blue.shade50,
child: const Text(
'This example chains commands: Create → Refresh List',
style: TextStyle(fontStyle: FontStyle.italic),
),
),
if (isFetching)
const LinearProgressIndicator()
else
const SizedBox(height: 4),
Expanded(
child: todos.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (isFetching)
const CircularProgressIndicator()
else
const Text('No todos'),
],
),
)
: ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return ListTile(
title: Text(todo.title),
subtitle: Text(todo.description),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
di<TodoManager>().deleteTodoCommand.run(todo.id);
// Chain: after deleting, refresh
Future.delayed(
const Duration(milliseconds: 100),
() => di<TodoManager>().fetchTodosCommand.run(),
);
},
),
);
},
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: isCreating
? null
: () {
final params = CreateTodoParams(
title: 'New Todo ${todos.length + 1}',
description: 'Created at ${DateTime.now()}',
);
di<TodoManager>().createTodoCommand.run(params);
},
child: isCreating
? const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
height: 20,
width: 20,
child:
CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 12),
Text('Creating & Refreshing...'),
],
)
: const Text('Create Todo (will auto-refresh)'),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: isFetching
? null
: () => di<TodoManager>().fetchTodosCommand.run(),
child: const Text('Manual Refresh'),
),
),
],
),
),
],
),
);
}
}Patrones de encadenamiento:
- Crear → Refrescar lista
- Login → Navegar a home
- Eliminar → Refrescar
- Subir → Procesar → Notificar
Mejores Prácticas
1. Watch vs Handler
Usa watch cuando:
- Necesites reconstruir el widget
- Mostrar indicadores de carga
- Mostrar resultados
- Mostrar mensajes de error inline
Usa registerHandler cuando:
- Navegación después de éxito
- Mostrar diálogos/snackbars
- Logging/analytics
- Disparar otros commands
- Cualquier efecto secundario que no requiere reconstrucción
2. No Hagas Await run()
// ✓ GOOD - Non-blocking, UI stays responsive
class DontAwaitExecuteGood extends WatchingWidget {
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () => di<TodoManager>().createTodoCommand.run(
CreateTodoParams(title: 'New todo', description: 'Description'),
),
child: Text('Submit'),
);
}
}// ❌ BAD - Blocks UI thread
class DontAwaitExecuteBad extends WatchingWidget {
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () async {
await di<TodoManager>().createTodoCommand.runAsync(
CreateTodoParams(title: 'New todo', description: 'Description'),
);
},
child: Text('Submit'),
);
}
}¿Por qué? Los commands manejan async internamente. Solo llama run() y deja que watch_it actualice la UI reactivamente.
3. Observa Estado de Ejecución para Carga
// ✓ GOOD - Watch isRunning
class WatchExecutionStateGood extends WatchingWidget {
@override
Widget build(BuildContext context) {
final command = createOnce(() => Command());
final isLoading = watch(command.isRunning).value;
if (isLoading) {
return CircularProgressIndicator();
}
return Container();
}
}Evita rastreo manual: No uses setState y flags booleanos. Deja que commands y watch_it manejen el estado reactivamente.
Patrones Comunes
Envío de Formulario
@override
Widget build(BuildContext context) {
final isSubmitting = watchValue((Manager m) => m.submitCommand.isRunning);
final canSubmit = formKey.currentState?.validate() ?? false;
return ElevatedButton(
onPressed: canSubmit && !isSubmitting
? () => di<Manager>().submitCommand.run()
: null,
child: isSubmitting
? const CircularProgressIndicator()
: const Text('Submit'),
);
}Pull to Refresh
// Pull to refresh pattern
class PullToRefreshPattern extends WatchingWidget {
@override
Widget build(BuildContext context) {
final todos = watchValue((TodoManager m) => m.fetchTodosCommand);
return RefreshIndicator(
onRefresh: di<TodoManager>().fetchTodosCommand.runAsync,
child: todos.isEmpty
? ListView(
children: const [
Center(child: Text('No todos - pull to refresh')),
],
)
: ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) => ListTile(
title: Text(todos[index].title),
),
),
);
}
}Ver También
- command_it Documentation - Aprende sobre commands
- Watch Functions - Todas las funciones watch
- Handler Pattern - Usar handlers
- Best Practices - Mejores prácticas generales