Observing Commands with watch_it
One of the most powerful combinations in the flutter_it ecosystem is using watch_it to observe command_it commands. Commands are ValueListenable objects that expose their state (isRunning, value, errors) as ValueListenable properties, making them naturally observable by watch_it. This pattern provides reactive, declarative state management for async operations with automatic loading states, error handling, and result updates.
Learn about Commands First
If you're new to command_it, start with the command_it Getting Started guide to understand how commands work.
Why watch_it + command_it?
Commands encapsulate async operations and track their execution state (isRunning, value, errors). watch_it allows your widgets to reactively rebuild when these states change, creating a seamless user experience without manual state management.
Benefits:
- Automatic loading states - No need to manually track
isLoadingbooleans - Reactive results - UI updates automatically when command completes
- Built-in error handling - Commands track errors,
watch_itdisplays them - Clean separation - Business logic in commands, UI logic in widgets
- No boilerplate - No
setState, noStreamBuilder, no manual listeners
Watching a Command
A typical pattern is to watch both the command's result and its execution state as separate values:
class TodoLoadingWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
// Load data on first build
callOnce((_) {
di<TodoManager>().fetchTodosCommand.run();
});
// Watch command's isRunning property to show loading state
final isLoading =
watchValue((TodoManager m) => m.fetchTodosCommand.isRunning);
// Watch the command itself to get its value (List<TodoModel>)
// Commands are ValueListenables, so watching them gives you their current value
final todos = watchValue((TodoManager m) => m.fetchTodosCommand);
return Scaffold(
appBar: AppBar(title: const Text('Watch Command - Loading State')),
body: Column(
children: [
// Show loading indicator when command is executing
if (isLoading)
const LinearProgressIndicator()
else
const SizedBox(height: 4),
Expanded(
child: isLoading && todos.isEmpty
? const Center(child: CircularProgressIndicator())
: todos.isEmpty
? const Center(child: Text('No todos'))
: ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return ListTile(
title: Text(todo.title),
subtitle: Text(todo.description),
);
},
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: ElevatedButton(
// Disable button while loading
onPressed: isLoading
? null
: () => di<TodoManager>().fetchTodosCommand.run(),
child: const Text('Refresh'),
),
),
],
),
);
}
}Key points:
- Watch the command itself to get its value (the result)
- Watch
command.isRunningto get the execution state - Widget rebuilds automatically when either changes
- Commands are
ValueListenableobjects, so they work seamlessly withwatch_it - Button disables during execution
- Progress indicator shows while loading
Watching Command Errors
Display errors by watching the command's errors property:
class CommandErrorWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
callOnce((_) {
di<TodoManager>().fetchTodosCommand.run();
});
// Watch command's errors property to display error messages
final error = watchValue((TodoManager m) => m.fetchTodosCommand.errors);
final isLoading =
watchValue((TodoManager m) => m.fetchTodosCommand.isRunning);
final todos = watchValue((TodoManager m) => m.fetchTodosCommand);
return Scaffold(
appBar: AppBar(title: const Text('Watch Command - Errors')),
body: Column(
children: [
// Display error banner when command fails
if (error != null)
Container(
color: Colors.red.shade100,
padding: const EdgeInsets.all(16),
child: Row(
children: [
const Icon(Icons.error, color: Colors.red),
const SizedBox(width: 8),
Expanded(
child: Text(
'Error: ${error.toString()}',
style: const TextStyle(color: Colors.red),
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
// Clear error by executing again
di<TodoManager>().fetchTodosCommand.run();
},
),
],
),
),
if (isLoading)
const LinearProgressIndicator()
else
const SizedBox(height: 4),
Expanded(
child: todos.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (error != null) ...[
const Icon(Icons.error_outline,
size: 64, color: Colors.red),
const SizedBox(height: 16),
const Text('Failed to load todos'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () =>
di<TodoManager>().fetchTodosCommand.run(),
child: const Text('Retry'),
),
] else if (isLoading)
const CircularProgressIndicator()
else
const Text('No todos'),
],
),
)
: ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return ListTile(
title: Text(todo.title),
subtitle: Text(todo.description),
);
},
),
),
],
),
);
}
}Error handling patterns:
- Show error banner at top of screen
- Display error message inline
- Provide retry button
- Clear errors on retry
Using Handlers for Side Effects
While watch is for rebuilding UI, use registerHandler for side effects like navigation or showing toasts:
Success Handler
class CreateTodoWithHandlerWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
final titleController = createOnce(() => TextEditingController());
final descController = createOnce(() => TextEditingController());
// Use registerHandler to handle successful command completion
// This is perfect for navigation, showing success messages, etc.
registerHandler(
select: (TodoManager m) => m.createTodoCommand,
handler: (context, result, _) {
// Show success snackbar
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Created: ${result!.title}'),
backgroundColor: Colors.green,
),
);
// Navigate back with result
Navigator.of(context).pop(result);
},
);
final isCreating =
watchValue((TodoManager m) => m.createTodoCommand.isRunning);
return Scaffold(
appBar: AppBar(title: const Text('Command Handler - Success')),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
const Text(
'This example uses registerHandler to navigate on success',
style: TextStyle(fontStyle: FontStyle.italic),
),
const SizedBox(height: 24),
TextField(
controller: titleController,
decoration: const InputDecoration(
labelText: 'Title',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextField(
controller: descController,
decoration: const InputDecoration(
labelText: 'Description',
border: OutlineInputBorder(),
),
maxLines: 3,
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: isCreating
? null
: () {
final params = CreateTodoParams(
title: titleController.text,
description: descController.text,
);
di<TodoManager>().createTodoCommand.run(params);
},
child: isCreating
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Create Todo'),
),
),
],
),
),
);
}
}Common success side effects:
- Navigate to another screen
- Show success snackbar/toast
- Trigger another command
- Log analytics event
Error Handler
class CommandErrorHandlerWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
callOnce((_) {
di<TodoManager>().fetchTodosCommand.run();
});
// Use registerHandler to handle command errors
// Shows error dialog or snackbar when command fails
registerHandler(
select: (TodoManager m) => m.fetchTodosCommand.errors,
handler: (context, error, _) {
// Show error dialog
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Error'),
content: Text(error.toString()),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
di<TodoManager>().fetchTodosCommand.run();
},
child: const Text('Retry'),
),
],
),
);
},
);
final isLoading =
watchValue((TodoManager m) => m.fetchTodosCommand.isRunning);
final todos = watchValue((TodoManager m) => m.fetchTodosCommand);
return Scaffold(
appBar: AppBar(title: const Text('Command Handler - Errors')),
body: Column(
children: [
const Padding(
padding: EdgeInsets.all(16.0),
child: Text(
'This example uses registerHandler to show error dialogs',
style: TextStyle(fontStyle: FontStyle.italic),
),
),
if (isLoading) const LinearProgressIndicator(),
Expanded(
child: isLoading && todos.isEmpty
? const Center(child: CircularProgressIndicator())
: todos.isEmpty
? const Center(child: Text('No todos'))
: ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return ListTile(
title: Text(todo.title),
subtitle: Text(todo.description),
);
},
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: ElevatedButton(
onPressed: isLoading
? null
: () => di<TodoManager>().fetchTodosCommand.run(),
child: const Text('Refresh'),
),
),
],
),
);
}
}Common error side effects:
- Show error dialog
- Show error snackbar
- Log error to crash reporting
- Retry logic
Handler Lifecycle
If you rely on your handler reacting to all state changes, ensure the widget where the handler is registered isn't destroyed and rebuilt during command execution.
Common pitfall: A button that registers a handler and calls a command, but the parent widget rebuilds on an onHover event - this destroys and recreates the button (and its handler), causing missed state changes.
Solution: Move the handler to a parent widget that will live for the entire duration of the command execution.
Note: This doesn't apply to watch functions - their results are only used in the same build function, so widget rebuilds don't cause issues.
Watching Command Results
The results property provides a CommandResult object containing all command state in one place:
class CommandResultsWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
callOnce((_) => di<TodoManager>().fetchTodosCommand.run());
// Watch the command's results property which contains all state:
// - data: The command's value
// - isRunning: Execution state
// - hasError: Whether an error occurred
// - error: The error if any
return watchValue(
(TodoManager m) => m.fetchTodosCommand.results,
).toWidget(
onData: (todos, param) => ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) => ListTile(
title: Text(todos[index].title),
subtitle: Text(todos[index].description),
),
),
onError: (error, lastResult, param) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text('Error: $error'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => di<TodoManager>().fetchTodosCommand.run(),
child: const Text('Retry'),
),
],
),
),
whileRunning: (lastResult, param) => const Center(
child: CircularProgressIndicator(),
),
);
}
}CommandResult contains:
data- The command's current valueisRunning- Whether the command is executinghasError- Whether an error occurrederror- The error object if anyisSuccess- Whether execution succeeded (!isRunning && !hasError)
The .toWidget() extension:
onData- Build UI when data is availableonError- Build UI when an error occurs (shows last successful result if available)whileRunning- Build UI while command is executing
This pattern is ideal when you need to handle all command states in a declarative way.
Other Command Properties
You can also watch other command properties individually:
command.isRunning- Execution statecommand.errors- Error notificationscommand.canRun- Whether the command can currently execute (combines!isRunning && !restriction)
Chaining Commands
Use handlers to chain commands together:
class CommandChainingWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
callOnce((_) {
di<TodoManager>().fetchTodosCommand.run();
});
// Use registerHandler to chain commands
// When create succeeds, automatically refresh the list
registerHandler(
select: (TodoManager m) => m.createTodoCommand,
handler: (context, result, _) {
// Chain: after creating, fetch the updated list
di<TodoManager>().fetchTodosCommand.run();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Created "${result!.title}" and refreshed list'),
backgroundColor: Colors.green,
),
);
},
);
final isCreating =
watchValue((TodoManager m) => m.createTodoCommand.isRunning);
final isFetching =
watchValue((TodoManager m) => m.fetchTodosCommand.isRunning);
final todos = watchValue((TodoManager m) => m.fetchTodosCommand);
return Scaffold(
appBar: AppBar(title: const Text('Command Chaining')),
body: Column(
children: [
Container(
padding: const EdgeInsets.all(16),
color: Colors.blue.shade50,
child: const Text(
'This example chains commands: Create → Refresh List',
style: TextStyle(fontStyle: FontStyle.italic),
),
),
if (isFetching)
const LinearProgressIndicator()
else
const SizedBox(height: 4),
Expanded(
child: todos.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (isFetching)
const CircularProgressIndicator()
else
const Text('No todos'),
],
),
)
: ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return ListTile(
title: Text(todo.title),
subtitle: Text(todo.description),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
di<TodoManager>().deleteTodoCommand.run(todo.id);
// Chain: after deleting, refresh
Future.delayed(
const Duration(milliseconds: 100),
() => di<TodoManager>().fetchTodosCommand.run(),
);
},
),
);
},
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: isCreating
? null
: () {
final params = CreateTodoParams(
title: 'New Todo ${todos.length + 1}',
description: 'Created at ${DateTime.now()}',
);
di<TodoManager>().createTodoCommand.run(params);
},
child: isCreating
? const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
height: 20,
width: 20,
child:
CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 12),
Text('Creating & Refreshing...'),
],
)
: const Text('Create Todo (will auto-refresh)'),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: isFetching
? null
: () => di<TodoManager>().fetchTodosCommand.run(),
child: const Text('Manual Refresh'),
),
),
],
),
),
],
),
);
}
}Chaining patterns:
- Create → Refresh list
- Login → Navigate to home
- Delete → Refresh
- Upload → Process → Notify
Best Practices
1. Watch vs Handler
Use watch when:
- You need to rebuild the widget
- Showing loading indicators
- Displaying results
- Showing error messages inline
Use registerHandler when:
- Navigation after success
- Showing dialogs/snackbars
- Logging/analytics
- Triggering other commands
- Any side effect that doesn't require rebuild
2. Don't Await run()
// ✓ GOOD - Non-blocking, UI stays responsive
class DontAwaitExecuteGood extends WatchingWidget {
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () => di<TodoManager>().createTodoCommand.run(
CreateTodoParams(title: 'New todo', description: 'Description'),
),
child: Text('Submit'),
);
}
}// ❌ BAD - Blocks UI thread
class DontAwaitExecuteBad extends WatchingWidget {
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () async {
await di<TodoManager>().createTodoCommand.runAsync(
CreateTodoParams(title: 'New todo', description: 'Description'),
);
},
child: Text('Submit'),
);
}
}Why? Commands handle async internally. Just call run() and let watch_it update the UI reactively.
3. Watch Execution State for Loading
// ✓ GOOD - Watch isRunning
class WatchExecutionStateGood extends WatchingWidget {
@override
Widget build(BuildContext context) {
final command = createOnce(() => Command());
final isLoading = watch(command.isRunning).value;
if (isLoading) {
return CircularProgressIndicator();
}
return Container();
}
}Avoid manual tracking: Don't use setState and boolean flags. Let commands and watch_it handle state reactively.
Common Patterns
Form Submission
@override
Widget build(BuildContext context) {
final isSubmitting = watchValue((Manager m) => m.submitCommand.isRunning);
final canSubmit = formKey.currentState?.validate() ?? false;
return ElevatedButton(
onPressed: canSubmit && !isSubmitting
? () => di<Manager>().submitCommand.run()
: null,
child: isSubmitting
? const CircularProgressIndicator()
: const Text('Submit'),
);
}Pull to Refresh
// Pull to refresh pattern
class PullToRefreshPattern extends WatchingWidget {
@override
Widget build(BuildContext context) {
final todos = watchValue((TodoManager m) => m.fetchTodosCommand);
return RefreshIndicator(
onRefresh: di<TodoManager>().fetchTodosCommand.runAsync,
child: todos.isEmpty
? ListView(
children: const [
Center(child: Text('No todos - pull to refresh')),
],
)
: ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) => ListTile(
title: Text(todos[index].title),
),
),
);
}
}See Also
- command_it Documentation - Learn about commands
- Watch Functions - All watch functions
- Handler Pattern - Using handlers
- Best Practices - General best practices