The author is available for consulting & contract workBook a call →
Skip to content

Using Commands without watch_it ​

All the examples in Getting Started use watch_it, which is our recommended approach for production apps. However, commands work perfectly with plain ValueListenableBuilder or any state management solution that can observe a Listenable (like Provider or Riverpod).

Quick Navigation ​

ApproachBest For
ValueListenableBuilderLearning, prototyping, no DI needed
CommandBuilderSimplest approach with state-aware builders
CommandResultSingle builder for all command states
StatefulWidget + .listen()Side effects (dialogs, navigation)
ProviderExisting Provider apps
RiverpodExisting Riverpod apps
flutter_hooksDirect watch-style calls (similar to watch_it!)
Bloc/CubitWhy commands replace Bloc for async state

When to Use ValueListenableBuilder ​

Consider using ValueListenableBuilder instead of watch_it when:

  • You're prototyping or learning and want to minimize dependencies
  • You have a simple widget that doesn't need dependency injection
  • You prefer explicit builder patterns over implicit observation
  • You're working on a project that doesn't use get_it

For production apps, we still recommend watch_it for cleaner, more maintainable code.

Easiest Approach: CommandBuilder

If you want the simplest way to use commands without watch_it, consider CommandBuilder - a widget that handles all command states with minimal boilerplate. It's cleaner than manual ValueListenableBuilder patterns. Jump to CommandBuilder example or see Command Builders for complete documentation.

Simple Counter Example ​

Here's the basic counter example using 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'),
        ),
      ],
    );
  }
}

Key points:

  • Use ValueListenableBuilder to observe the command
  • Use StatelessWidget instead of WatchingWidget
  • No need for get_it registration - service can be created directly in the widget
  • Command is still a ValueListenable, just observed differently

Async Example with Loading States ​

Here's the weather example showing async commands with loading indicators:

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

Key points:

  • Watch isRunning with a separate ValueListenableBuilder for loading state
  • Nested builders required - one for loading state, one for data
  • More verbose than watch_it but works without additional dependencies
  • All command features (async, error handling, restrictions) still work

Comparing the Approaches ​

For watch_it examples, see Observing Commands with watch_it.

Aspectwatch_itValueListenableBuilder
DependenciesRequires get_it + watch_itNo additional dependencies
Widget BaseWatchingWidgetStatelessWidget or StatefulWidget
ObservationwatchValue((Service s) => s.command)ValueListenableBuilder(valueListenable: command, ...)
Multiple PropertiesClean - separate watchValue callsNested builders required
BoilerplateMinimalMore verbose
Recommended ForProduction appsLearning, prototyping

Using CommandResult ​

For the cleanest ValueListenableBuilder experience, use CommandResult to observe all command state in a single builder:

dart
class MyWidget extends StatelessWidget {
  final myCommand = Command.createAsync<void, String>(
    () async => 'Hello',
    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);
      },
    );
  }
}

See Command Results for more details on using CommandResult.

StatefulWidget Patterns ​

When you need to react to command events (like errors or state changes) without rebuilding the UI, use a StatefulWidget with .listen() subscriptions in initState.

Error Handling with .listen() ​

Here's how to handle errors and show dialogs using 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)'),
                  ),
                ],
              ),
          ],
        );
      },
    );
  }
}

Key points:

  • Subscribe to .errors in initState - runs once, not on every build
  • Use .where((e) => e != null) to filter out null values (emitted at execution start)
  • CRITICAL: Cancel subscriptions in dispose() to prevent memory leaks
  • Store StreamSubscription to cancel later
  • Check mounted before showing dialogs to avoid errors on disposed widgets
  • Dispose the command in dispose() to clean up resources

When to use StatefulWidget + .listen():

  • Need to react to events (errors, state changes) with side effects
  • Want to show dialogs, trigger navigation, or log events
  • Prefer explicit subscription management

Important: Always cancel subscriptions in dispose() to prevent memory leaks!

Want Automatic Cleanup?

For automatic subscription cleanup, consider using watch_it's registerHandler - see Observing Commands with watch_it for patterns that eliminate manual subscription management.

For more error handling patterns, see Command Properties - Error Notifications.

Observing canRun ​

The canRun property automatically combines the command's restriction state and execution state, making it perfect for enabling/disabling UI elements:

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

Key points:

  • canRun is false when command is running OR restricted
  • Perfect for button onPressed - automatically disables during execution
  • Cleaner than manually checking both isRunning and restriction state
  • Updates automatically when either state changes

Choosing Your Approach ​

When using commands without watch_it, you have several options:

CommandBuilder (Easiest) ​

Best for: Simplest approach with dedicated builders for each state

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: Cleanest code, separate builders for each state, no manual state checking Cons: Additional widget in tree

See Command Builders for complete documentation.

ValueListenableBuilder with CommandResult ​

Best for: Most cases - single builder handles all states

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: Clean, all state in one place, no nesting Cons: Rebuilds UI on every state change

Nested ValueListenableBuilders ​

Best for: When you need different rebuild granularity

dart
ValueListenableBuilder<bool>(
  valueListenable: command.isRunning,
  builder: (context, isRunning, _) {
    if (isRunning) return LoadingWidget();
    return ValueListenableBuilder<TResult>(
      valueListenable: command,
      builder: (context, data, _) => DataWidget(data),
    );
  },
)

Pros: Fine-grained control over rebuilds Cons: Nesting can get complex with multiple properties

StatefulWidget + .listen() ​

Best for: Side effects (dialogs, navigation, 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();  // CRITICAL: Prevent memory leaks
    super.dispose();
  }
}

Pros: Separate side effects from UI, runs once, full control Cons: Must manage subscriptions manually, more boilerplate

Decision tree:

  1. Want simplest approach? → CommandBuilder
  2. Need side effects (dialogs, navigation)? → StatefulWidget + .listen()
  3. Observing multiple states? → CommandResult
  4. Need fine-grained rebuilds? → Nested builders

Want Even Cleaner Code?

watch_it's registerHandler provides automatic subscription cleanup. See Observing Commands with watch_it if you want to eliminate manual subscription management entirely.

Integration with Other State Management Solutions ​

Commands integrate well with other state management solutions (watch_it is ours). Since each command property (isRunning, errors, results, etc.) is itself a ValueListenable, any solution that can observe a Listenable can watch them with granular rebuilds.

Provider Integration ​

Use ListenableProvider to watch specific command properties:

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

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

Key points:

  • Use context.read<Manager>() to get the manager without listening
  • Use ListenableProvider.value() to provide specific command properties
  • Each property (isRunning, results, etc.) is a separate Listenable
  • Only the widgets watching that specific property rebuild when it changes

Riverpod Integration ​

With Riverpod's @riverpod annotation, create providers for specific command properties:

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

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

Key points:

  • Use Raw<T> wrapper to prevent Riverpod from auto-disposing the notifiers
  • Use ref.onDispose() to clean up commands when the provider is disposed
  • Create separate providers for each command property you want to observe
  • Requires riverpod_annotation package and code generation (build_runner)

flutter_hooks Integration ​

flutter_hooks provides a direct watch-style pattern very similar to watch_it! Use useValueListenable for clean, declarative observation:

Manager setup:

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

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

Key points:

  • useValueListenable provides direct watch-style calls - no nested builders!
  • Pattern is very similar to watch_it's watchValue
  • Each useValueListenable call observes a specific property for granular rebuilds
  • Requires flutter_hooks package

About Bloc/Cubit ​

Commands and Bloc/Cubit solve the same problem - managing async operation state. Using both creates redundancy:

Featurecommand_itBloc/Cubit
Loading statecommand.isRunningLoadingState()
Error handlingcommand.errorsErrorState(error)
Result/Datacommand.valueLoadedState(data)
Executioncommand.run()emit() / add(Event)
Restrictionscommand.canRunManual logic
Progress trackingcommand.progressManual implementation

Recommendation: Choose one approach. If you're already using Bloc/Cubit for async operations, you don't need commands for those operations. If you want to use commands, they replace the need for Bloc/Cubit in async state management.

Next Steps ​

Ready to learn more?

For more about watch_it and why we recommend it, see the watch_it documentation.

Released under the MIT License. Built by Thomas Burkhart.