Usando Commands sin watch_it
Todos los ejemplos en Primeros Pasos usan watch_it, que es nuestro enfoque recomendado para apps en producción. Sin embargo, los commands funcionan perfectamente con ValueListenableBuilder simple o cualquier solución de gestión de estado que pueda observar un Listenable (como Provider o Riverpod).
Navegación Rápida
| Enfoque | Mejor Para |
|---|---|
| ValueListenableBuilder | Aprendizaje, prototipado, no se necesita DI |
| CommandBuilder | Enfoque más simple con builders conscientes de estado |
| CommandResult | Un solo builder para todos los estados del command |
| StatefulWidget + .listen() | Efectos secundarios (diálogos, navegación) |
| Provider | Apps existentes con Provider |
| Riverpod | Apps existentes con Riverpod |
| flutter_hooks | Llamadas directas estilo watch (¡similar a watch_it!) |
| Bloc/Cubit | Por qué los commands reemplazan Bloc para estado async |
Cuándo Usar ValueListenableBuilder
Considera usar ValueListenableBuilder en lugar de watch_it cuando:
- Estás prototipando o aprendiendo y quieres minimizar dependencias
- Tienes un widget simple que no necesita inyección de dependencias
- Prefieres patrones de builder explícitos sobre observación implícita
- Estás trabajando en un proyecto que no usa
get_it
Para apps en producción, aún recomendamos watch_it para código más limpio y mantenible.
Enfoque Más Fácil: CommandBuilder
Si quieres la forma más simple de usar commands sin watch_it, considera CommandBuilder - un widget que maneja todos los estados del command con mínimo boilerplate. Es más limpio que patrones manuales de ValueListenableBuilder. Salta a ejemplo de CommandBuilder o ver Command Builders para documentación completa.
Ejemplo Simple de Contador
Aquí está el ejemplo básico de contador usando ValueListenableBuilder:
class CounterModel {
int _count = 0;
// Command wraps a function and acts as a ValueListenable
late final incrementCommand = Command.createSyncNoParam<String>(
() {
_count++;
return _count.toString();
},
initialValue: '0',
);
}
class CounterWidget extends StatelessWidget {
CounterWidget({super.key});
final model = CounterModel();
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('You have pushed the button this many times:'),
// Command is a ValueListenable - use ValueListenableBuilder
ValueListenableBuilder<String>(
valueListenable: model.incrementCommand,
builder: (context, value, _) => Text(
value,
style: Theme.of(context).textTheme.headlineMedium,
),
),
SizedBox(height: 16),
// Command has a .run method - use it as tearoff for onPressed
ElevatedButton(
onPressed: model.incrementCommand.run,
child: Text('Increment'),
),
],
);
}
}Puntos clave:
- Usa
ValueListenableBuilderpara observar el command - Usa
StatelessWidgeten lugar deWatchingWidget - No se necesita registro en
get_it- el servicio puede crearse directamente en el widget - El command sigue siendo un
ValueListenable, solo se observa diferente
Ejemplo Async con Estados de Carga
Aquí está el ejemplo de clima mostrando commands async con indicadores de carga:
class WeatherWidget extends StatelessWidget {
WeatherWidget({super.key});
final manager = WeatherManager();
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<bool>(
valueListenable: manager.loadWeatherCommand.isRunning,
builder: (context, isRunning, _) {
// Show loading indicator while command runs
if (isRunning) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Loading weather data...'),
],
),
);
}
// Show data when ready
return ValueListenableBuilder<List<WeatherEntry>>(
valueListenable: manager.loadWeatherCommand,
builder: (context, weather, _) {
if (weather.isEmpty) {
return Center(
child: ElevatedButton(
onPressed: () => manager.loadWeatherCommand('London'),
child: Text('Load Weather'),
),
);
}
return ListView.builder(
itemCount: weather.length,
itemBuilder: (context, index) {
final entry = weather[index];
return ListTile(
title: Text(entry.city),
subtitle: Text(entry.condition),
trailing: Text('${entry.temperature}°F'),
);
},
);
},
);
},
);
}
}Puntos clave:
- Observa
isRunningcon unValueListenableBuilderseparado para estado de carga - Se requieren builders anidados - uno para estado de carga, uno para datos
- Más verbose que
watch_itpero funciona sin dependencias adicionales - Todas las características de commands (async, manejo de errores, restricciones) aún funcionan
Comparando los Enfoques
Para ejemplos de watch_it, ver Observando Commands con watch_it.
| Aspecto | watch_it | ValueListenableBuilder |
|---|---|---|
| Dependencias | Requiere get_it + watch_it | Sin dependencias adicionales |
| Widget Base | WatchingWidget | StatelessWidget o StatefulWidget |
| Observación | watchValue((Service s) => s.command) | ValueListenableBuilder(valueListenable: command, ...) |
| Múltiples Propiedades | Limpio - llamadas watchValue separadas | Se requieren builders anidados |
| Boilerplate | Mínimo | Más verbose |
| Recomendado Para | Apps en producción | Aprendizaje, prototipado |
Usando CommandResult
Para la experiencia más limpia con ValueListenableBuilder, usa CommandResult para observar todo el estado del command en un solo builder:
class MyWidget extends StatelessWidget {
final myCommand = Command.createAsync<void, String>(
() async => 'Hola',
initialValue: '',
);
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<CommandResult<void, String>>(
valueListenable: myCommand.results,
builder: (context, result, _) {
if (result.isRunning) {
return CircularProgressIndicator();
}
if (result.hasError) {
return Text('Error: ${result.error}');
}
return Text(result.data);
},
);
}
}Ver Command Results para más detalles sobre usar CommandResult.
Patrones con StatefulWidget
Cuando necesitas reaccionar a eventos del command (como errores o cambios de estado) sin reconstruir la UI, usa un StatefulWidget con suscripciones .listen() en initState.
Manejo de Errores con .listen()
Aquí está cómo manejar errores y mostrar diálogos usando StatefulWidget:
class DataWidget extends StatefulWidget {
const DataWidget({super.key});
@override
State<DataWidget> createState() => _DataWidgetState();
}
class _DataWidgetState extends State<DataWidget> {
final manager = DataManager();
ListenableSubscription? _errorSubscription;
@override
void initState() {
super.initState();
// Subscribe to errors in initState - runs once, not on every build
_errorSubscription = manager.loadDataCommand.errors
.where((e) => e != null) // Filter out null values
.listen((error, _) {
// Show error dialog
if (mounted) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Error'),
content: Text(error!.error.toString()),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('OK'),
),
],
),
);
}
});
}
@override
void dispose() {
// CRITICAL: Cancel subscription to prevent memory leaks
_errorSubscription?.cancel();
manager.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<CommandResult<void, List<Todo>>>(
valueListenable: manager.loadDataCommand.results,
builder: (context, result, _) {
return Column(
children: [
if (result.hasError)
Padding(
padding: const EdgeInsets.all(8),
child: Text(
result.error.toString(),
style: const TextStyle(color: Colors.red),
),
),
if (result.isRunning)
const CircularProgressIndicator()
else
Column(
children: [
ElevatedButton(
onPressed: manager.loadDataCommand.run,
child: const Text('Load Data'),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: () {
manager.shouldFail = true;
manager.loadDataCommand.run();
},
child: const Text('Load Data (will fail)'),
),
],
),
],
);
},
);
}
}Puntos clave:
- Suscríbete a
.errorseninitState- se ejecuta una vez, no en cada build - Usa
.where((e) => e != null)para filtrar valores null (emitidos al inicio de ejecución) - CRÍTICO: Cancela suscripciones en
dispose()para prevenir memory leaks - Almacena
StreamSubscriptionpara cancelar después - Verifica
mountedantes de mostrar diálogos para evitar errores en widgets disposed - Dispone el command en
dispose()para limpiar recursos
Cuándo usar StatefulWidget + .listen():
- Necesitas reaccionar a eventos (errores, cambios de estado) con efectos secundarios
- Quieres mostrar diálogos, disparar navegación, o loguear eventos
- Prefieres gestión explícita de suscripciones
Importante: ¡Siempre cancela suscripciones en dispose() para prevenir memory leaks!
¿Quieres Limpieza Automática?
Para limpieza automática de suscripciones, considera usar registerHandler de watch_it - ver Observando Commands con watch_it para patrones que eliminan la gestión manual de suscripciones.
Para más patrones de manejo de errores, ver Propiedades del Command - Notificaciones de Error.
Observando canRun
La propiedad canRun automáticamente combina el estado de restricción y el estado de ejecución del command, haciéndola perfecta para habilitar/deshabilitar elementos de UI:
class DataWidget extends StatelessWidget {
final manager = DataManager();
DataWidget({super.key});
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Observe canRun to enable/disable button
ValueListenableBuilder<bool>(
valueListenable: manager.loadDataCommand.canRun,
builder: (context, canRun, _) {
return ElevatedButton(
onPressed: canRun ? manager.loadDataCommand.run : null,
child: const Text('Load Data'),
);
},
),
const SizedBox(height: 16),
// Observe results to show data/loading/error
ValueListenableBuilder<CommandResult<void, List<Todo>>>(
valueListenable: manager.loadDataCommand.results,
builder: (context, result, _) {
if (result.isRunning) {
return const CircularProgressIndicator();
}
if (result.hasError) {
return Text(
'Error: ${result.error}',
style: const TextStyle(color: Colors.red),
);
}
return Text('Loaded ${result.data?.length ?? 0} todos');
},
),
],
);
}
}Puntos clave:
canRunesfalsecuando el command está ejecutándose O restringido- Perfecto para
onPressedde botón - deshabilita automáticamente durante ejecución - Más limpio que verificar manualmente tanto
isRunningcomo estado de restricción - Se actualiza automáticamente cuando cualquier estado cambia
Eligiendo Tu Enfoque
Cuando usas commands sin watch_it, tienes varias opciones:
CommandBuilder (Lo Más Fácil)
Mejor para: Enfoque más simple con builders dedicados para cada estado
CommandBuilder(
command: loadDataCommand,
whileRunning: (context, _, __) => CircularProgressIndicator(),
onError: (context, error, _, __) => Text('Error: $error'),
onData: (context, data, _) => ListView(
children: data.map((item) => ListTile(title: Text(item))).toList(),
),
)Pros: Código más limpio, builders separados para cada estado, sin verificación manual de estado Contras: Widget adicional en el árbol
Ver Command Builders para documentación completa.
ValueListenableBuilder con CommandResult
Mejor para: La mayoría de casos - un solo builder maneja todos los estados
ValueListenableBuilder<CommandResult<TParam, TResult>>(
valueListenable: command.results,
builder: (context, result, _) {
if (result.isRunning) return LoadingWidget();
if (result.hasError) return ErrorWidget(result.error);
return DataWidget(result.data);
},
)Pros: Limpio, todo el estado en un solo lugar, sin anidación Contras: Reconstruye UI en cada cambio de estado
ValueListenableBuilders Anidados
Mejor para: Cuando necesitas diferente granularidad de rebuilds
ValueListenableBuilder<bool>(
valueListenable: command.isRunning,
builder: (context, isRunning, _) {
if (isRunning) return LoadingWidget();
return ValueListenableBuilder<TResult>(
valueListenable: command,
builder: (context, data, _) => DataWidget(data),
);
},
)Pros: Control granular sobre rebuilds Contras: La anidación puede volverse compleja con múltiples propiedades
StatefulWidget + .listen()
Mejor para: Efectos secundarios (diálogos, navegación, logging)
class _MyWidgetState extends State<MyWidget> {
ListenableSubscription? _subscription;
@override
void initState() {
super.initState();
_subscription = command.errors
.where((e) => e != null)
.listen((error, _) {
if (mounted) showDialog(...);
});
}
@override
void dispose() {
_subscription?.cancel(); // CRÍTICO: Prevenir memory leaks
super.dispose();
}
}Pros: Separa efectos secundarios de UI, se ejecuta una vez, control total Contras: Debe gestionar suscripciones manualmente, más boilerplate
Árbol de decisión:
- ¿Quieres el enfoque más simple? → CommandBuilder
- ¿Necesitas efectos secundarios (diálogos, navegación)? → StatefulWidget + .listen()
- ¿Observando múltiples estados? → CommandResult
- ¿Necesitas rebuilds granulares? → Builders anidados
¿Quieres Código Aún Más Limpio?
registerHandler de watch_it proporciona limpieza automática de suscripciones. Ver Observando Commands con watch_it si quieres eliminar la gestión manual de suscripciones completamente.
Integración con Otras Soluciones de Gestión de Estado
Los Commands se integran bien con otras soluciones de gestión de estado (watch_it es la nuestra). Como cada propiedad del command (isRunning, errors, results, etc.) es en sí un ValueListenable, cualquier solución que pueda observar un Listenable puede observarlas con rebuilds granulares.
Integración con Provider
Usa ListenableProvider para observar propiedades específicas del command:
/// Manager that holds commands - provided via ChangeNotifierProvider
class TodoManager extends ChangeNotifier {
final ApiClient _api;
TodoManager(this._api);
late final loadCommand = Command.createAsyncNoParam<List<Todo>>(
() => _api.fetchTodos(),
initialValue: [],
);
late final toggleCommand = Command.createAsync<String, void>(
(id) async {
final todo = loadCommand.value.firstWhere((t) => t.id == id);
_api.toggleTodo(id, !todo.completed);
loadCommand.run(); // Refresh list
},
initialValue: null,
);
@override
void dispose() {
loadCommand.dispose();
toggleCommand.dispose();
super.dispose();
}
}Setup con ChangeNotifierProvider:
/// App setup with Provider
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => TodoManager(ApiClient()),
child: const MaterialApp(home: TodoScreen()),
);
}
}Observación granular con ListenableProvider:
/// Watch specific command properties for granular rebuilds
class TodoScreen extends StatelessWidget {
const TodoScreen({super.key});
@override
Widget build(BuildContext context) {
// Get manager without listening (we'll watch specific properties)
final manager = context.read<TodoManager>();
return Scaffold(
appBar: AppBar(title: const Text('Todos')),
body: Column(
children: [
// Watch just isRunning for loading indicator
ListenableProvider<ValueListenable<bool>>.value(
value: manager.loadCommand.isRunning,
child: Consumer<ValueListenable<bool>>(
builder: (context, isRunning, _) {
if (isRunning.value) {
return const LinearProgressIndicator();
}
return const SizedBox.shrink();
},
),
),
// Watch command results for the list
Expanded(
child: ListenableProvider<
ValueListenable<CommandResult<void, List<Todo>>>>.value(
value: manager.loadCommand.results,
child: Consumer<ValueListenable<CommandResult<void, List<Todo>>>>(
builder: (context, resultsNotifier, _) {
final result = resultsNotifier.value;
if (result.hasError) {
return Center(child: Text('Error: ${result.error}'));
}
final todos = result.data!;
if (todos.isEmpty) {
return const Center(child: Text('No todos'));
}
return ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return ListTile(
title: Text(todo.title),
leading: Checkbox(
value: todo.completed,
onChanged: (_) => manager.toggleCommand.run(todo.id),
),
);
},
);
},
),
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: manager.loadCommand.run,
child: const Icon(Icons.refresh),
),
);
}
}Puntos clave:
- Usa
context.read<Manager>()para obtener el manager sin escuchar - Usa
ListenableProvider.value()para proporcionar propiedades específicas del command - Cada propiedad (
isRunning,results, etc.) es unListenableseparado - Solo los widgets observando esa propiedad específica se reconstruyen cuando cambia
Integración con Riverpod
Con la anotación @riverpod de Riverpod, crea providers para propiedades específicas del command:
/// Manager provider with cleanup
@riverpod
TodoManager todoManager(Ref ref) {
final manager = TodoManager(ApiClient());
ref.onDispose(() => manager.dispose());
return manager;
}
/// Granular provider for isRunning - only rebuilds when loading state changes
@riverpod
Raw<ValueListenable<bool>> isLoading(Ref ref) {
return ref.watch(todoManagerProvider).loadCommand.isRunning;
}
/// Granular provider for results - only rebuilds when results change
@riverpod
Raw<ValueListenable<CommandResult<void, List<Todo>>>> loadResults(Ref ref) {
return ref.watch(todoManagerProvider).loadCommand.results;
}En tu widget:
/// Widget using granular providers
class TodoScreen extends ConsumerWidget {
const TodoScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isLoading = ref.watch(isLoadingProvider).value;
final result = ref.watch(loadResultsProvider).value;
final manager = ref.read(todoManagerProvider);
return Scaffold(
appBar: AppBar(title: const Text('Todos')),
body: Builder(
builder: (context) {
if (isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (result.hasError) {
return Center(child: Text('Error: ${result.error}'));
}
final todos = result.data!;
if (todos.isEmpty) {
return const Center(child: Text('No todos'));
}
return ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return ListTile(
title: Text(todo.title),
leading: Checkbox(
value: todo.completed,
onChanged: (_) => manager.toggleCommand.run(todo.id),
),
);
},
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: manager.loadCommand.run,
child: const Icon(Icons.refresh),
),
);
}
}Puntos clave:
- Usa wrapper
Raw<T>para prevenir que Riverpod auto-dispose los notifiers - Usa
ref.onDispose()para limpiar commands cuando el provider se dispone - Crea providers separados para cada propiedad del command que quieras observar
- Requiere paquete
riverpod_annotationy generación de código (build_runner)
Integración con flutter_hooks
flutter_hooks proporciona un patrón de observación directa muy similar a watch_it! Usa useValueListenable para observación limpia y declarativa:
Setup del manager:
/// Manager that holds commands - can be registered in get_it or any DI
class TodoManager {
final ApiClient _api;
TodoManager(this._api);
late final loadCommand = Command.createAsyncNoParam<List<Todo>>(
() => _api.fetchTodos(),
initialValue: [],
);
late final toggleCommand = Command.createAsync<String, void>(
(id) async {
final todo = loadCommand.value.firstWhere((t) => t.id == id);
_api.toggleTodo(id, !todo.completed);
loadCommand.run();
},
initialValue: null,
);
void dispose() {
loadCommand.dispose();
toggleCommand.dispose();
}
}En tu widget:
/// Widget using flutter_hooks - similar to watch_it pattern!
class TodoScreen extends HookWidget {
const TodoScreen({super.key});
@override
Widget build(BuildContext context) {
final manager = getIt<TodoManager>();
// Direct watch-style calls - like watch_it!
final isLoading = useValueListenable(manager.loadCommand.isRunning);
final result = useValueListenable(manager.loadCommand.results);
return Scaffold(
appBar: AppBar(title: const Text('Todos')),
body: Builder(
builder: (context) {
if (isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (result.hasError) {
return Center(child: Text('Error: ${result.error}'));
}
final todos = result.data!;
if (todos.isEmpty) {
return const Center(child: Text('No todos'));
}
return ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return ListTile(
title: Text(todo.title),
leading: Checkbox(
value: todo.completed,
onChanged: (_) => manager.toggleCommand.run(todo.id),
),
);
},
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: manager.loadCommand.run,
child: const Icon(Icons.refresh),
),
);
}
}Puntos clave:
useValueListenableproporciona llamadas directas estilo watch - ¡sin builders anidados!- El patrón es muy similar a
watchValuedewatch_it - Cada llamada a
useValueListenableobserva una propiedad específica para rebuilds granulares - Requiere paquete
flutter_hooks
Sobre Bloc/Cubit
Commands y Bloc/Cubit resuelven el mismo problema - gestionar estado de operaciones async. Usar ambos crea redundancia:
| Característica | command_it | Bloc/Cubit |
|---|---|---|
| Estado de carga | command.isRunning | LoadingState() |
| Manejo de errores | command.errors | ErrorState(error) |
| Resultado/Datos | command.value | LoadedState(data) |
| Ejecución | command.run() | emit() / add(Event) |
| Restricciones | command.canRun | Lógica manual |
| Tracking de progreso | command.progress | Implementación manual |
Recomendación: Elige un enfoque. Si ya estás usando Bloc/Cubit para operaciones async, no necesitas commands para esas operaciones. Si quieres usar commands, reemplazan la necesidad de Bloc/Cubit en gestión de estado async.
Siguientes Pasos
¿Listo para aprender más?
- ¿Quieres usar
watch_it? Ver Observando Commands conwatch_itpara patrones comprehensivos - ¿Necesitas más características de commands? Revisa Propiedades del Command, Manejo de Errores (Error Handling), y Restricciones
- ¿Construyendo apps de producción? Lee Mejores Prácticas para guía de arquitectura
Para más sobre watch_it y por qué lo recomendamos, ver la documentación de watch_it.