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
| Approach | Best For |
|---|---|
| ValueListenableBuilder | Learning, prototyping, no DI needed |
| CommandBuilder | Simplest approach with state-aware builders |
| CommandResult | Single builder for all command states |
| StatefulWidget + .listen() | Side effects (dialogs, navigation) |
| Provider | Existing Provider apps |
| Riverpod | Existing Riverpod apps |
| flutter_hooks | Direct watch-style calls (similar to watch_it!) |
| Bloc/Cubit | Why 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:
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
ValueListenableBuilderto observe the command - Use
StatelessWidgetinstead ofWatchingWidget - No need for
get_itregistration - 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:
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
isRunningwith a separateValueListenableBuilderfor loading state - Nested builders required - one for loading state, one for data
- More verbose than
watch_itbut 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.
| Aspect | watch_it | ValueListenableBuilder |
|---|---|---|
| Dependencies | Requires get_it + watch_it | No additional dependencies |
| Widget Base | WatchingWidget | StatelessWidget or StatefulWidget |
| Observation | watchValue((Service s) => s.command) | ValueListenableBuilder(valueListenable: command, ...) |
| Multiple Properties | Clean - separate watchValue calls | Nested builders required |
| Boilerplate | Minimal | More verbose |
| Recommended For | Production apps | Learning, prototyping |
Using CommandResult
For the cleanest ValueListenableBuilder experience, use CommandResult to observe all command state in a single builder:
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:
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
.errorsininitState- 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
StreamSubscriptionto cancel later - Check
mountedbefore 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:
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:
canRunisfalsewhen command is running OR restricted- Perfect for button
onPressed- automatically disables during execution - Cleaner than manually checking both
isRunningand 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
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
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
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)
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:
- Want simplest approach? → CommandBuilder
- Need side effects (dialogs, navigation)? → StatefulWidget + .listen()
- Observing multiple states? → CommandResult
- 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:
/// 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:
/// 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:
/// 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 separateListenable - Only the widgets watching that specific property rebuild when it changes
Riverpod Integration
With Riverpod's @riverpod annotation, create providers for specific command properties:
/// 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:
/// 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_annotationpackage 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:
/// 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:
/// 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:
useValueListenableprovides direct watch-style calls - no nested builders!- Pattern is very similar to
watch_it'swatchValue - Each
useValueListenablecall observes a specific property for granular rebuilds - Requires
flutter_hookspackage
About Bloc/Cubit
Commands and Bloc/Cubit solve the same problem - managing async operation state. Using both creates redundancy:
| Feature | command_it | Bloc/Cubit |
|---|---|---|
| Loading state | command.isRunning | LoadingState() |
| Error handling | command.errors | ErrorState(error) |
| Result/Data | command.value | LoadedState(data) |
| Execution | command.run() | emit() / add(Event) |
| Restrictions | command.canRun | Manual logic |
| Progress tracking | command.progress | Manual 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?
- Want to use
watch_it? See Observing Commands withwatch_itfor comprehensive patterns - Need more command features? Check out Command Properties, Error Handling, and Restrictions
- Building production apps? Read Best Practices for architecture guidance
For more about watch_it and why we recommend it, see the watch_it documentation.