Observing Commands with watch_it
WARNING
This content is AI generated and is currently under review.
One of the most powerful combinations in the flutter_it ecosystem is using watch_it to observe command_it commands. This pattern provides reactive, declarative state management for async operations with automatic loading states, error handling, and result updates.
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 Command Execution State
The most common pattern is watching isRunning to show loading indicators:
class TodoLoadingWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
// Watch command's isRunning property to show loading state
// This is the most common pattern for reactive loading indicators
final isLoading =
watchValue((TodoManager m) => m.fetchTodosCommand.isRunning);
final todos = watchValue((TodoManager m) => m.todos);
// Load data on first build
callOnce((_) {
di<TodoManager>().fetchTodosCommand.run();
});
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:
command.isRunningis aValueListenable<bool>- Widget rebuilds automatically when command starts/stops
- Button disables during execution
- Progress indicator shows while loading
Watching Command Results
Watch the command's value property to display results:
class WeatherResultWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
// Get the command from the manager
final manager = di<WeatherManager>();
// Watch the command to get its result value
final weather = watch(manager.fetchWeatherCommand).value;
final isLoading = watch(manager.fetchWeatherCommand.isRunning).value;
callOnce((_) {
di<WeatherManager>().fetchWeatherCommand.run();
});
return Scaffold(
appBar: AppBar(title: const Text('Watch Command - Result Value')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (isLoading)
const CircularProgressIndicator()
else if (weather != null) ...[
Text(
weather.location,
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 16),
Text(
'${weather.temperature}°C',
style: Theme.of(context).textTheme.displayMedium,
),
const SizedBox(height: 8),
Text(
weather.condition,
style: Theme.of(context).textTheme.titleLarge,
),
] else
const Text('No weather data'),
const SizedBox(height: 24),
ElevatedButton(
onPressed: isLoading
? null
: () => di<WeatherManager>().fetchWeatherCommand.run(),
child: const Text('Refresh Weather'),
),
],
),
),
);
}
}Pattern:
// Get the command
void watchValuePattern(BuildContext context) {
final manager = di<WeatherManager>();
// Watch its value
final weather = watch(manager.fetchWeatherCommand).value;
final isLoading = watch(manager.fetchWeatherCommand.isRunning).value;
}Watching Command Errors
Display errors by watching the command's errors property:
class CommandErrorWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
final manager = di<TodoManager>();
// Watch command's errors property to display error messages
final error = watch(manager.fetchTodosCommand.errors).value;
final isLoading = watch(manager.fetchTodosCommand.isRunning).value;
final todos = watchValue((TodoManager m) => m.todos);
callOnce((_) {
di<TodoManager>().fetchTodosCommand.run();
});
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
manager.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: () =>
manager.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, _) {
if (result != null) {
// 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) {
// Use registerHandler to handle command errors
// Shows error dialog or snackbar when command fails
registerHandler(
select: (TodoManager m) => m.fetchTodosCommand.errors,
handler: (context, error, _) {
if (error != null) {
// 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.todos);
callOnce((_) {
di<TodoManager>().fetchTodosCommand.run();
});
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
Loading Button Pattern
A complete pattern for buttons that show loading state:
class LoadingButtonWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
final manager = di<TodoManager>();
// Watch command execution to show inline loading state in button
final isRunning = watch(manager.fetchTodosCommand.isRunning).value;
final todos = watchValue((TodoManager m) => m.todos);
return Scaffold(
appBar: AppBar(title: const Text('Command Loading Button')),
body: Column(
children: [
Expanded(
child: todos.isEmpty
? const Center(child: Text('No todos - click button to load'))
: 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: SizedBox(
width: double.infinity,
child: ElevatedButton(
// Disable button while executing
onPressed: isRunning
? null
: () => manager.fetchTodosCommand.run(),
child: isRunning
? const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<Color>(Colors.white),
),
),
SizedBox(width: 12),
Text('Loading...'),
],
)
: const Text('Load Todos'),
),
),
),
],
),
);
}
}This pattern:
- Disables button during execution (
onPressed: isRunning ? null : ...) - Shows inline loading indicator
- Provides visual feedback to user
- Prevents double-submission
Watching Multiple Command States
You can watch different aspects of the same command:
class MultipleCommandStatesWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
final manager = di<TodoManager>();
// Watch multiple aspects of the same command
final isCreating = watch(manager.createTodoCommand.isRunning).value;
final createResult = watch(manager.createTodoCommand).value;
final createError = watch(manager.createTodoCommand.errors).value;
// Watch another command's states
final isFetching = watch(manager.fetchTodosCommand.isRunning).value;
final todos = watchValue((TodoManager m) => m.todos);
callOnce((_) {
manager.fetchTodosCommand.run();
});
return Scaffold(
appBar: AppBar(title: const Text('Multiple Command States')),
body: Column(
children: [
// Status indicators for multiple commands
Container(
padding: const EdgeInsets.all(16),
color: Colors.grey.shade100,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text('Fetch Status: ',
style: TextStyle(fontWeight: FontWeight.bold)),
if (isFetching)
const Row(
children: [
SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 8),
Text('Loading...'),
],
)
else
Text('${todos.length} todos loaded'),
],
),
const SizedBox(height: 8),
Row(
children: [
const Text('Create Status: ',
style: TextStyle(fontWeight: FontWeight.bold)),
if (isCreating)
const Row(
children: [
SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 8),
Text('Creating...'),
],
)
else if (createError != null)
const Text('Error', style: TextStyle(color: Colors.red))
else if (createResult != null)
const Text('Success',
style: TextStyle(color: Colors.green))
else
const Text('Idle'),
],
),
],
),
),
Expanded(
child: 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: Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: isFetching
? null
: () => manager.fetchTodosCommand.run(),
child: const Text('Refresh'),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton(
onPressed: isCreating
? null
: () {
final params = CreateTodoParams(
title: 'Quick Todo ${todos.length + 1}',
description: 'Created at ${DateTime.now()}',
);
manager.createTodoCommand.run(params);
},
child: const Text('Create'),
),
),
],
),
),
],
),
);
}
}Watch multiple properties:
command.isRunning- Is it running?command.value- What's the result?command.errors- Did it fail?command.canRun- Can it run now?
Chaining Commands
Use handlers to chain commands together:
class CommandChainingWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
final manager = di<TodoManager>();
// Use registerHandler to chain commands
// When create succeeds, automatically refresh the list
registerHandler(
select: (TodoManager m) => m.createTodoCommand,
handler: (context, result, _) {
if (result != null) {
// Chain: after creating, fetch the updated list
manager.fetchTodosCommand.run();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Created "${result.title}" and refreshed list'),
backgroundColor: Colors.green,
),
);
}
},
);
final isCreating = watch(manager.createTodoCommand.isRunning).value;
final isFetching = watch(manager.fetchTodosCommand.isRunning).value;
final todos = watchValue((TodoManager m) => m.todos);
callOnce((_) {
manager.fetchTodosCommand.run();
});
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: () {
manager.deleteTodoCommand.run(todo.id);
// Chain: after deleting, refresh
Future.delayed(
const Duration(milliseconds: 100),
() => manager.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()}',
);
manager.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
: () => manager.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.
4. Handle Errors Gracefully
// ✓ GOOD - Watch errors and display them
class HandleErrorsGoodWatch extends WatchingWidget {
@override
Widget build(BuildContext context) {
final command = createOnce(() => Command());
final error = watch(command.errors).value;
if (error != null) {
return Text('Error: $error');
}
return Container();
}
}// ✓ ALSO GOOD - Use handler for error dialog
class HandleErrorsGoodHandler extends WatchingWidget {
@override
Widget build(BuildContext context) {
registerHandler(
select: (TodoManager m) => m.createTodoCommand.errors,
handler: (context, error, _) {
if (error != null) {
showDialog(
context: context,
builder: (_) => AlertDialog(
title: Text('Error'),
content: Text('$error'),
),
);
}
},
);
return Container();
}
}5. Only Show Loading on Initial Load
// Show spinner only when no data yet
class InitialLoadPattern extends WatchingWidget {
@override
Widget build(BuildContext context) {
final command = di<TodoManager>().fetchTodosCommand;
final isLoading = watch(command.isRunning).value;
final data = watch(command).value;
// Show spinner only when no data yet
if (isLoading && data.isEmpty) {
return CircularProgressIndicator();
}
// Show data even while refreshing
return ListView(
children: [
if (isLoading) LinearProgressIndicator(), // Subtle indicator
...data.map((item) => ListTile(title: Text(item.title))),
],
);
}
}Common Patterns
Form Submission
// Form submission pattern
class FormSubmissionPattern extends WatchingWidget {
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
final formData = FormData('Example');
@override
Widget build(BuildContext context) {
final manager = di<Manager>();
final isSubmitting = watch(manager.submitCommand.isRunning).value;
final canSubmit = formKey.currentState?.validate() ?? false;
return ElevatedButton(
onPressed: canSubmit && !isSubmitting
? () => manager.submitCommand.run()
: null,
child: isSubmitting ? CircularProgressIndicator() : Text('Submit'),
);
}
}Pull to Refresh
// Pull to refresh pattern
class PullToRefreshPattern extends WatchingWidget {
@override
Widget build(BuildContext context) {
final manager = di<TodoManager>();
return RefreshIndicator(
onRefresh: () async {
manager.fetchTodosCommand.run();
await manager.fetchTodosCommand.runAsync();
},
child: ListView(children: []),
);
}
}Retry on Error
// Retry on error pattern
class RetryOnErrorPattern extends WatchingWidget {
@override
Widget build(BuildContext context) {
final command = createOnce(() => Command());
final error = watch(command.errors).value;
if (error != null) {
return Column(
children: [
Text('Error: $error'),
ElevatedButton(
onPressed: () => command.run(),
child: Text('Retry'),
),
],
);
}
return Container();
}
}See Also
- command_it Documentation - Learn about commands
- Watch Functions - All watch functions
- Handler Pattern - Using handlers
- Best Practices - General best practices