A comprehensive guide to using the watch_it package for reactive state management in Flutter, integrated with the command_it package for handling async operations.
The most common pattern for observing reactive state changes from managers.
Pattern:
// Watch specific manager properties
final data = watchValue((DataManager m) => m.data);
final isLoading = watchValue((DataManager m) => m.command.isRunning);
// Watch command state
final result = watchValue((DataManager m) => m.fetchCommand);Multiple Properties Example:
final userState = watchValue((UserManager m) => m.userState);
final settings = watchValue((SettingsManager m) => m.settings);
final isEnabled = watchValue((AppState s) => s.isFeatureEnabled);Watching Filters:
final location = watchValue((FilterManager m) => m.location);
final category = watchValue((FilterManager m) => m.category);
final sortOrder = watchValue((FilterManager m) => m.sortOrder);
final tags = watchValue((FilterManager m) => m.tags);Used for initialization logic that should run only once, similar to initState but in a stateless context.
Pattern:
callOnce((_) {
// Initialize data, trigger commands
di<Manager>().loadCommand.run();
});Examples:
// Load initial data on first build
callOnce((_) {
di<DataManager>().fetchDataCommand.run();
di<DataManager>().loadSettingsCommand.run();
});// Conditional initialization
callOnce((_) {
if (di<DataManager>().needsRefresh) {
di<DataManager>().refreshCommand.run();
}
});// Initialize with context
callOnce((context) {
di<TrackingManager>().markAsViewed(widget.itemId);
});// Simple initialization
callOnce((_) => manager.initFields());Registers handlers for command results, errors, or value changes. Replaces traditional .listen() callbacks with widget-lifecycle-aware handlers.
Pattern:
// Success handler
registerHandler(
select: (Manager m) => m.command,
handler: (context, result, _) {
if (result != null) {
// Handle success
}
},
);
// Error handler
registerHandler(
select: (Manager m) => m.command.errors,
handler: (context, error, _) {
// Show error toast/snackbar
},
);Success Handler Example:
// Navigate after successful creation
registerHandler(
select: (DataManager m) => m.createCommand,
handler: (context, result, _) async {
if (result != null) {
Navigator.of(context).pop();
// Optional: trigger related actions
di<RelatedManager>().refreshCommand.run();
}
},
);Error Handler Example:
// Show error toast
registerHandler(
select: (DataManager m) => m.loadCommand.errors,
handler: (context, error, _) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to load data: ${error.toString()}')),
);
},
);Multiple Handlers for One Command:
// Error handler
registerHandler(
target: command.errors,
handler: (context, error, _) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: ${error.toString()}')),
);
},
);
// Success handler
registerHandler(
target: command,
handler: (context, result, _) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Success!')),
);
// Auto-close page if needed
if (shouldAutoClose) {
Navigator.of(context).pop(result);
}
},
);Auto-Close on Success:
registerHandler(
select: (Manager m) => m.createCommand,
handler: (context, item, _) {
if (item != null) {
Navigator.of(context).pop(item);
}
},
);
registerHandler(
select: (Manager m) => m.updateCommand,
handler: (context, item, _) {
if (item != null) {
Navigator.of(context).pop(item);
}
},
);Specialized handler for stream-based events, commonly used with event buses.
Pattern:
registerStreamHandler<Stream<EventType>, EventType>(
target: di<EventBus>().on<EventType>(eventKey),
handler: (context, snapshot, _) {
// Handle stream event
},
);Examples:
// Listen to creation events
registerStreamHandler<Stream<ItemCreatedEvent>, ItemCreatedEvent>(
target: di<EventBus>().on<ItemCreatedEvent>(Events.itemCreated),
handler: (context, snapshot, _) {
_handleNewItem(snapshot.data?.item);
},
);
// Listen to update events
registerStreamHandler(
target: di<EventBus>().on(Events.dataUpdated),
handler: (context, snapshot, _) {
_refreshData();
},
);Creates an object once per widget lifecycle, automatically disposing it when the widget is disposed.
Pattern:
final dataSource = createOnce(() => createDataSource());Examples:
// Create feed source
final dataSource = createOnce(() => item.createFeedSource());// Create paginated source
final feedSource = createOnce(
() => item.createRelatedItemsSource(),
);// Create typed source
final reviewsSource = createOnce<ReviewsFeedSource>(
() => di<Manager>().createReviewsSource(itemId),
);Typical Usage with Pagination:
final feedSource = createOnce(() => createFeedSource());
final isLoading = watch(feedSource.isFetchingNextPage).value;
final itemCount = watch(feedSource.itemCount).value;
final errors = watch(feedSource.errors).value;Low-level watching API for watching entire objects (not specific properties).
Pattern:
watch(object); // Watch entire object for changes
final value = watch(object.property).value; // Watch property with .value accessExamples:
// Watch command execution state
final isLoading = watch(command.isRunning).value;// Watch data source properties
final isLoading = watch(dataSource.isFetchingNextPage).value;
final itemCount = watch(dataSource.itemCount).value;// Watch multiple properties
watch(dataSource.itemCount);
final isLoading = watch(dataSource.isFetchingNextPage).value;
final errors = watch(dataSource.errors).value;// Watch inherited data
final proxy = watch(InheritedData.of(context).proxy);
final isLoading = watch(proxy.updateCommand.isRunning).value;// Watch entire object for any changes
watch(dataObject);Used when you don't need traditional StatefulWidget lifecycle or local state - watch_it provides all the lifecycle you need.
Example:
class MyWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
callOnce((_) {
di<Manager>().loadCommand.run();
});
final data = watchValue((Manager m) => m.data);
registerHandler(
select: (Manager m) => m.command,
handler: (context, result, _) {
if (result != null) {
// Handle success
}
},
);
return content;
}
}Used when you need local widget state (setState) alongside reactive state management.
Example:
class MyPage extends WatchingStatefulWidget {
const MyPage({super.key, required this.itemId});
final String itemId;
@override
State<MyPage> createState() => _MyPageState();
}
class _MyPageState extends State<MyPage> {
String? _selectedOption;
bool _isExpanded = false;
@override
Widget build(BuildContext context) {
// Use both watchValue and setState
final data = watchValue((DataManager m) => m.data);
// Later, use setState to update local state
onOptionChanged(String? option) {
setState(() {
_selectedOption = option;
});
}
return content;
}
}With Animation Controllers:
class AnimatedButton extends WatchingStatefulWidget {
const AnimatedButton({
super.key,
required this.onTap,
});
final VoidCallback onTap;
@override
State<AnimatedButton> createState() => _AnimatedButtonState();
}
class _AnimatedButtonState extends State<AnimatedButton>
with TickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 1),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// Use watch_it for reactive state
final isLoading = watch(di<Manager>().command.isRunning).value;
return AnimatedBuilder(
animation: _controller,
builder: (context, child) => content,
);
}
}Commands are created using Command.createAsync* factory methods from the command_it package.
Examples:
// No parameter, returns list
late final loadItemsCommand = Command.createAsyncNoParam<List<Item>>(
() async {
final api = ItemApi(di<ApiClient>());
final response = await api.getItems();
return response.map(Item.fromDto).toList();
},
debugName: 'loadItems',
);
// No parameter, no result
late final initializeCommand = Command.createAsyncNoParamNoResult(
() async {
await di<Service>().initialize();
},
debugName: 'initialize',
);
// With parameters, returns result
late final createItemCommand = Command.createAsync<CreateItemParams, Item?>(
(params) async {
final api = ItemApi(di<ApiClient>());
final dto = await api.createItem(
title: params.title,
description: params.description,
);
return Item.fromDto(dto);
},
debugName: 'createItem',
errorFilter: const LocalOnlyErrorFilter(),
);Command with Complex Logic:
late final deleteItemCommand = Command.createAsyncNoParamNoResult(
() async {
final api = ItemApi(di<ApiClient>());
await api.deleteItem(id);
// Refresh related data
await loadItemsCommand.runAsync();
// Update parent if exists
if (parentId != null) {
final parent = await di<Manager>().getItemById(parentId!);
parent.refreshCommand.run();
}
},
debugName: 'deleteItem',
errorFilter: const CustomErrorFilter(),
);Non-blocking (fire-and-forget):
// Don't await - UI remains responsive
command.run();Blocking (wait for result):
// Use when you need the result
await command.runAsync();In UI (preferred pattern):
ElevatedButton(
onPressed: () => di<Manager>().command.run(), // No await!
child: Text('Submit'),
)Pattern:
final isLoading = watchValue((Manager m) => m.command.isRunning);
final data = watchValue((Manager m) => m.command.value);
// In UI
if (isLoading) {
return const CircularProgressIndicator();
}Button with Loading State:
ElevatedButton(
onPressed: canSubmit && !isSubmitting
? () => di<Manager>().submitCommand.run(data)
: null,
child: isSubmitting
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Text('Submit'),
)Use error filters to control how errors are handled:
Basic Error Filter:
late final command = Command.createAsync<Params, Result>(
(params) async {
// ... command logic
},
debugName: 'myCommand',
errorFilter: const LocalOnlyErrorFilter(), // Handle locally, don't log globally
);Error Listener Pattern:
late final command = Command.createAsyncNoParamNoResult(
() async {
// ... command logic
},
debugName: 'myCommand',
errorFilter: const CustomErrorFilter(),
)..errors.listen((error, stackTrace) {
// Handle error globally
print('Command failed: $error');
});registerHandler(
target: command.errors,
handler: (context, error, _) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error: ${error.toString()}'),
),
);
},
);The app uses get_it (which watch_it builds on) for dependency injection:
import 'package:watch_it/watch_it.dart';
void setupDi() {
// Configure command error reporting
Command.reportAllExceptions = false;
// Register singletons
di.registerSingleton<AppState>(appState);
di.registerSingleton<StorageService>(storageService);
di.registerSingleton<ApiClient>(apiClient);
// Register lazy singletons (created when first accessed)
di.registerLazySingleton<UserManager>(() => UserManager());
di.registerLazySingleton<DataManager>(() => DataManager());
di.registerLazySingleton<SettingsManager>(() => SettingsManager());
}Pattern:
// Direct access - NOT passed as constructor parameter
final manager = di<DataManager>();
final data = watchValue((DataManager m) => m.data);Key Principle: If an object can be accessed via DI, don't pass it as a widget constructor parameter. Widgets should be self-contained and access dependencies internally.
Good:
class MyWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
final manager = di<DataManager>();
final data = watchValue((DataManager m) => m.data);
callOnce((_) => manager.loadCommand.run());
return content;
}
}Bad (anti-pattern):
class MyWidget extends StatelessWidget {
const MyWidget({required this.manager}); // ❌ Don't do this for DI objects
final DataManager manager;
}Replace async methods with commands:
Good:
late final loadDataCommand = Command.createAsyncNoParam(
() async {
final result = await api.fetchData();
return result;
},
);
// In widget
callOnce((_) => di<Manager>().loadDataCommand.run());Bad:
Future<void> loadData() async { // ❌ Don't use raw async methods
final result = await api.fetchData();
}Only show spinner when data is null/empty, not on every execution:
final isLoading = watch(dataSource.isFetchingNextPage).value;
final itemCount = watch(dataSource.itemCount).value;
// Only show loading on initial load
if (!isLoading && itemCount == 0) {
return const Center(child: Text('No data'));
}
// Show content even when refreshing
return ListView.builder(
itemCount: itemCount,
itemBuilder: (context, index) => itemBuilder(index),
);You can register multiple handlers for different aspects:
// Success handler
registerHandler(
select: (Manager m) => m.command,
handler: (context, result, _) { /* handle success */ },
);
// Error handler
registerHandler(
select: (Manager m) => m.command.errors,
handler: (context, error, _) { /* handle error */ },
);
// Value change handler
registerHandler(
select: (Manager m) => m.someProperty,
handler: (context, value, _) { /* react to change */ },
);Feed sources are often created with createOnce and watched with watch:
final feedSource = createOnce(() => createFeedSource());
final isLoading = watch(feedSource.isFetchingNextPage).value;
final itemCount = watch(feedSource.itemCount).value;
final errors = watch(feedSource.errors).value;
// Use in list view
if (isLoading && itemCount == 0) {
return const CircularProgressIndicator();
}
return ListView.builder(
itemCount: itemCount,
itemBuilder: (context, index) => buildItem(index),
);// Register handler with conditional logic
registerHandler(
select: (Manager m) => m.selectedItem,
handler: (context, item, _) {
if (item?.needsValidation == true) {
showValidationDialog(context);
}
},
);// Execute another command based on result
registerHandler(
select: (Manager m) => m.firstCommand,
handler: (context, result, _) {
if (result?.isValid == true) {
di<Manager>().secondCommand.run(result);
}
},
);registerHandler(
select: (Manager m) => m.createCommand,
handler: (context, result, _) {
if (result != null) {
Navigator.of(context).pop(result);
}
},
);Use WatchItMixin to add watch_it capabilities to any widget without extending WatchingWidget:
class MyWidget<T> extends StatelessWidget with WatchItMixin {
@override
Widget build(BuildContext context) {
final data = watchValue((Manager m) => m.data);
callOnce((_) => di<Manager>().loadCommand.run());
return content;
}
}-
WatchingWidget replaces StatefulWidget for most cases - use
callOnceinstead ofinitState -
Commands over async methods - All async operations should use
Command.createAsync* -
No DI in constructors - Access managers via
di<Manager>()inside widgets -
registerHandler replaces .listen() - Widget-lifecycle-aware event handling
-
Non-blocking execution - Use
command.run()withoutawaitin UI -
Reactive loading states - Watch
command.isRunningfor UI feedback -
Error filters for different scenarios - Use appropriate filters for error handling strategy
-
createOnce for disposable objects - Automatically disposed when widget is disposed
-
Multiple registerHandler calls - One for success, one for errors, one for value changes
-
Stream handlers for events -
registerStreamHandlerfor event bus integration
| Function | Purpose | Typical Use Case |
|---|---|---|
watchValue |
Watch specific property | Reactive UI updates |
watch |
Watch entire object | Low-level watching |
callOnce |
One-time initialization | Replace initState |
registerHandler |
Handle command results | Success/error handling |
registerStreamHandler |
Handle stream events | Event bus integration |
createOnce |
Create disposable object | Data sources, controllers |
- Setup
get_itdependency injection - Create managers with commands instead of async methods
- Use
WatchingWidgetinstead ofStatefulWidgetwhere possible - Access DI objects inside widgets, not via constructor
- Use
watchValuefor reactive state - Use
callOncefor initialization - Use
registerHandlerfor success/error handling - Execute commands with
.run()(no await) - Watch
command.isRunningfor loading states - Use
createOncefor disposable objects