Skip to content

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).

EnfoqueMejor Para
ValueListenableBuilderAprendizaje, prototipado, no se necesita DI
CommandBuilderEnfoque más simple con builders conscientes de estado
CommandResultUn solo builder para todos los estados del command
StatefulWidget + .listen()Efectos secundarios (diálogos, navegación)
ProviderApps existentes con Provider
RiverpodApps existentes con Riverpod
flutter_hooksLlamadas directas estilo watch (¡similar a watch_it!)
Bloc/CubitPor 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:

dart
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 ValueListenableBuilder para observar el command
  • Usa StatelessWidget en lugar de WatchingWidget
  • 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:

dart
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 isRunning con un ValueListenableBuilder separado para estado de carga
  • Se requieren builders anidados - uno para estado de carga, uno para datos
  • Más verbose que watch_it pero 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.

Aspectowatch_itValueListenableBuilder
DependenciasRequiere get_it + watch_itSin dependencias adicionales
Widget BaseWatchingWidgetStatelessWidget o StatefulWidget
ObservaciónwatchValue((Service s) => s.command)ValueListenableBuilder(valueListenable: command, ...)
Múltiples PropiedadesLimpio - llamadas watchValue separadasSe requieren builders anidados
BoilerplateMínimoMás verbose
Recomendado ParaApps en producciónAprendizaje, prototipado

Usando CommandResult

Para la experiencia más limpia con ValueListenableBuilder, usa CommandResult para observar todo el estado del command en un solo builder:

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

dart
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 .errors en initState - 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 StreamSubscription para cancelar después
  • Verifica mounted antes 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:

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

  • canRun es false cuando el command está ejecutándose O restringido
  • Perfecto para onPressed de botón - deshabilita automáticamente durante ejecución
  • Más limpio que verificar manualmente tanto isRunning como 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

dart
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

dart
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

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

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

  1. ¿Quieres el enfoque más simple? → CommandBuilder
  2. ¿Necesitas efectos secundarios (diálogos, navegación)? → StatefulWidget + .listen()
  3. ¿Observando múltiples estados? → CommandResult
  4. ¿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:

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

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

dart
/// 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 un Listenable separado
  • 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:

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

dart
/// 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_annotation y 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:

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

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

  • useValueListenable proporciona llamadas directas estilo watch - ¡sin builders anidados!
  • El patrón es muy similar a watchValue de watch_it
  • Cada llamada a useValueListenable observa 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ísticacommand_itBloc/Cubit
Estado de cargacommand.isRunningLoadingState()
Manejo de errorescommand.errorsErrorState(error)
Resultado/Datoscommand.valueLoadedState(data)
Ejecucióncommand.run()emit() / add(Event)
Restriccionescommand.canRunLógica manual
Tracking de progresocommand.progressImplementació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?

Para más sobre watch_it y por qué lo recomendamos, ver la documentación de watch_it.

Publicado bajo la Licencia MIT.