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.