Side Effects with Handlers
You've learned watch() functions for rebuilding widgets. But what about actions that DON'T need a rebuild, like calling a function, navigation, showing toasts, or logging?
That's where handlers come in. Handlers can react to changes in ValueListenables, Listenables, Streams, and Futures without triggering widget rebuilds.
registerHandler - The Basics
registerHandler() runs a callback when data changes, but doesn't trigger a rebuild:
class CounterWidget extends WatchingWidget {
const CounterWidget({super.key});
@override
Widget build(BuildContext context) {
// Handler: Show snackbar when count reaches 10 (no rebuild needed)
registerHandler(
select: (CounterManager m) => m.count,
handler: (context, count, cancel) {
if (count == 10) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('You reached 10!')),
);
}
},
);
// Watch: Display the count (triggers rebuild)
final count = watchValue((CounterManager m) => m.count);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Count: $count', style: const TextStyle(fontSize: 24)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => di<CounterManager>().increment(),
child: const Text('Increment'),
),
],
);
}
}The pattern:
select- What to watch (likewatchValue)handler- What to do when it changes- Handler receives
context,value, andcancelfunction
Common Handler Patterns
Navigation on Success
class LoginScreen extends WatchingWidget {
@override
Widget build(BuildContext context) {
registerHandler(
select: (UserManager m) => m.currentUser,
handler: (context, user, cancel) {
if (user != null) {
// User logged in - navigate away
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => HomeScreen()),
);
}
},
);
return Container(); // Login form would go here
}
}Calling Business Functions
One of the most common uses of handlers is to call commands or methods on business objects in response to triggers:
class UserFormWidget extends WatchingWidget {
const UserFormWidget({super.key});
@override
Widget build(BuildContext context) {
// Handler triggers the save command on the business object
registerHandler(
select: (FormManager m) => m.onSubmitted,
handler: (context, _, cancel) {
// Call command on business object whenever triggered
di<UserService>().saveUserCommand.run();
},
);
// Optionally watch the command state to show loading indicator
final isSaving = watchValue(
(UserService s) => s.saveUserCommand.isRunning,
);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Your form fields here...
const TextField(decoration: InputDecoration(labelText: 'Name')),
const SizedBox(height: 16),
ElevatedButton(
onPressed: isSaving
? null
: () => di<FormManager>().submit(), // Trigger via manager
child:
isSaving ? const CircularProgressIndicator() : const Text('Save'),
),
],
);
}
}Key points:
- Handler watches a trigger (form submit, button press, etc.)
- Handler calls command/method on business object
- Same widget can optionally watch the command state (for loading indicators, etc.)
- Clear separation: handler triggers action, watch shows state
Show Snackbar
class TodoScreen extends WatchingWidget {
@override
Widget build(BuildContext context) {
registerHandler(
select: (TodoManager m) => m.todos,
handler: (context, todos, cancel) {
if (todos.isNotEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Todo list updated!')),
);
}
},
);
return Scaffold(
body: Center(child: Text('Todo Screen')),
);
}
}Watch vs Handler: When to Use Each
Use watch() when you need to REBUILD the widget:
class WatchVsHandlerWatch extends WatchingWidget {
@override
Widget build(BuildContext context) {
final todos = watchValue((TodoManager m) => m.todos);
return ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) => Text(todos[index].title),
);
}
}Use registerHandler() when you need a SIDE EFFECT (no rebuild):
class WatchVsHandlerHandler extends WatchingWidget {
@override
Widget build(BuildContext context) {
registerHandler(
select: (TodoManager m) => m.createTodoCommand,
handler: (context, result, cancel) {
// Navigate to detail page (no rebuild needed)
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => Scaffold()),
);
},
);
return Container();
}
}Complete Example: Todo Creation
This example combines multiple handler patterns - navigation on success, error handling, and watching loading state:
class TodoCreationPage extends WatchingWidget {
@override
Widget build(BuildContext context) {
final titleController = createOnce(() => TextEditingController());
final descController = createOnce(() => TextEditingController());
// registerHandler executes side effects when a ValueListenable changes
// Unlike watch, it does NOT rebuild the widget
// Perfect for navigation, showing toasts, logging, etc.
registerHandler(
select: (TodoManager m) => m.createTodoCommand,
handler: (context, result, _) {
// Handler only fires when command completes with result
// Show success message
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Created: ${result!.title}')),
);
// Navigate back
Navigator.of(context).pop(result);
},
);
// Handle errors separately
registerHandler(
select: (TodoManager m) => m.createTodoCommand.errors,
handler: (context, error, _) {
if (error != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error: ${error.toString()}'),
backgroundColor: Colors.red,
),
);
}
},
);
// Watch loading state to disable button
final isCreating =
watchValue((TodoManager m) => m.createTodoCommand.isRunning);
return Scaffold(
appBar: AppBar(title: const Text('Create Todo')),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextField(
controller: titleController,
decoration: const InputDecoration(labelText: 'Title'),
),
const SizedBox(height: 16),
TextField(
controller: descController,
decoration: const InputDecoration(labelText: 'Description'),
maxLines: 3,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: isCreating
? null
: () => di<TodoManager>().createTodoCommand.run(
CreateTodoParams(
title: titleController.text,
description: descController.text,
),
),
child: isCreating
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Create'),
),
],
),
),
);
}
}This example demonstrates:
- Watching command result for navigation
- Separate error handler with error UI
- Combining
registerHandler()(side effects) withwatchValue()(UI state) - Using
createOnce()for controllers
The cancel Parameter
All handlers receive a cancel function. Call it to stop reacting:
class CancelParameter extends WatchingWidget {
@override
Widget build(BuildContext context) {
registerHandler(
select: (DataManager m) => m.data,
handler: (context, value, cancel) {
if (value == 'STOP') {
cancel(); // Stop listening to future changes
}
},
);
return Container();
}
}Common use case: One-time actions
class WelcomeWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
registerHandler(
select: (DataManager m) => m.data,
handler: (context, data, cancel) {
if (data.isNotEmpty) {
// Show welcome dialog once
showDialog(
context: context,
builder: (_) => AlertDialog(
title: Text('Welcome!'),
content: Text('Data loaded: $data'),
),
);
cancel(); // Only show once
}
},
);
return Container();
}
}Handler Types
watch_it provides specialized handlers for different data types:
registerHandler - For ValueListenables
class RegisterHandlerGeneric extends WatchingWidget {
@override
Widget build(BuildContext context) {
registerHandler(
select: (DataManager m) => m.data,
handler: (context, value, cancel) {
print('Data changed: $value');
},
);
return Container();
}
}registerStreamHandler - For Streams
class EventListenerWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
final todos = watchValue((TodoManager m) => m.todos);
// registerStreamHandler listens to a stream and executes a handler
// for each event. Perfect for event buses, web socket messages, etc.
registerStreamHandler<Stream<TodoCreatedEvent>, TodoCreatedEvent>(
target: di<EventBus>().on<TodoCreatedEvent>(),
handler: (context, snapshot, _) {
if (snapshot.hasData) {
final event = snapshot.data!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('New todo created: ${event.todo.title}'),
duration: const Duration(seconds: 2),
),
);
}
},
);
// Listen to delete events
registerStreamHandler<Stream<TodoDeletedEvent>, TodoDeletedEvent>(
target: di<EventBus>().on<TodoDeletedEvent>(),
handler: (context, snapshot, _) {
if (snapshot.hasData) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Todo deleted'),
duration: Duration(seconds: 1),
),
);
}
},
);
return Scaffold(
appBar: AppBar(title: const Text('Event Listener')),
body: Column(
children: [
const Padding(
padding: EdgeInsets.all(16.0),
child: Text(
'This widget listens to todo events via stream handlers',
style: TextStyle(fontStyle: FontStyle.italic),
),
),
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: ElevatedButton(
onPressed: () {
// Simulate creating a todo and firing an event
final newTodo = TodoModel(
id: DateTime.now().toString(),
title: 'Test Todo ${todos.length + 1}',
description: 'Created at ${DateTime.now()}',
);
di<EventBus>().fire(TodoCreatedEvent(newTodo));
},
child: const Text('Fire Create Event'),
),
),
],
),
);
}
}Use when:
- Watching a Stream
- Want to react to each event
- Don't need to display the value (no rebuild)
registerFutureHandler - For Futures
class DataInitializationWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
// registerFutureHandler executes a handler when a future completes
// Useful for one-time initialization with side effects
registerFutureHandler(
select: (_) => di<DataService>().fetchTodos(),
handler: (context, snapshot, _) {
if (snapshot.hasData) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Loaded ${snapshot.data!.length} todos'),
backgroundColor: Colors.green,
),
);
} else if (snapshot.hasError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error loading todos: ${snapshot.error}'),
backgroundColor: Colors.red,
),
);
}
},
initialValue: const <TodoModel>[],
);
final todos = watchValue((TodoManager m) => m.todos);
return Scaffold(
appBar: AppBar(title: const Text('Future Handler Example')),
body: Column(
children: [
const Padding(
padding: EdgeInsets.all(16.0),
child: Text(
'This widget uses registerFutureHandler for initialization',
style: TextStyle(fontStyle: FontStyle.italic),
),
),
Expanded(
child: todos.isEmpty
? const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Loading...'),
],
),
)
: ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return ListTile(
title: Text(todo.title),
subtitle: Text(todo.description),
trailing: Checkbox(
value: todo.completed,
onChanged: (value) {},
),
);
},
),
),
],
),
);
}
}Use when:
- Watching a Future
- Want to run code when it completes
- Don't need to display the value
registerChangeNotifierHandler - For ChangeNotifier
class SettingsPage extends WatchingWidget {
@override
Widget build(BuildContext context) {
final settings = createOnce(() => SettingsModel());
// registerChangeNotifierHandler listens to a ChangeNotifier
// and executes a handler whenever it changes
// Useful for side effects like saving to storage, analytics, etc.
registerChangeNotifierHandler(
target: settings,
handler: (context, notifier, cancel) {
// Save settings whenever they change
debugPrint('Settings changed - saving to storage...');
// In real app: await StorageService.saveSettings(settings)
},
);
// Watch individual properties for UI updates
watch(settings);
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SwitchListTile(
title: const Text('Dark Mode'),
value: settings.darkMode,
onChanged: settings.setDarkMode,
),
const Divider(),
ListTile(
title: const Text('Language'),
subtitle: Text(settings.language),
trailing: DropdownButton<String>(
value: settings.language,
items: const [
DropdownMenuItem(value: 'en', child: Text('English')),
DropdownMenuItem(value: 'es', child: Text('Spanish')),
DropdownMenuItem(value: 'fr', child: Text('French')),
],
onChanged: (value) {
if (value != null) {
settings.setLanguage(value);
}
},
),
),
const Divider(),
ListTile(
title: const Text('Font Size'),
subtitle: Slider(
value: settings.fontSize,
min: 10,
max: 24,
divisions: 14,
label: settings.fontSize.round().toString(),
onChanged: settings.setFontSize,
),
),
],
),
),
);
}
}Use when:
- Watching a
ChangeNotifier - Need access to the full notifier object
- Want to trigger actions on any change
Advanced Patterns
Chaining Actions
Handlers excel at chaining actions - triggering one operation after another completes:
class UserListWidget extends WatchingWidget {
const UserListWidget({super.key});
@override
Widget build(BuildContext context) {
// Handler watches for save completion, then triggers reload
registerHandler(
select: (UserService s) => s.saveCompleted,
handler: (context, count, cancel) {
if (count > 0) {
// Chain action: trigger reload on another service
di<UserListService>().reloadCommand.run();
}
},
);
// Watch the reload state to show loading indicator
final isReloading = watchValue(
(UserListService s) => s.reloadCommand.isRunning,
);
final isSaving = watchValue(
(UserService s) => s.saveUserCommand.isRunning,
);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (isReloading)
const Column(
children: [
CircularProgressIndicator(),
SizedBox(height: 8),
Text('Reloading list...'),
],
)
else
const Text('User List'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: isSaving
? null
: () {
di<UserService>().saveUserCommand.run();
// Trigger the reload handler
di<UserService>().saveCompleted.value++;
},
child: isSaving
? const Text('Saving...')
: const Text('Save User (triggers reload)'),
),
],
);
}
}Key points:
- Handler watches for save completion
- Handler triggers reload on another service
- Common pattern: save → reload list, update → refresh data
- Each service remains independent
Error Handling
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'),
),
),
],
),
);
}
}Debounced Actions
class Pattern4DebouncedActions extends WatchingWidget {
@override
Widget build(BuildContext context) {
registerHandler(
select: (SimpleUserManager m) =>
m.name.debounce(Duration(milliseconds: 300)),
handler: (context, query, cancel) {
// Handler only fires after 300ms of no changes
print('Searching for: $query');
},
);
return Container();
}
}Optional Handler Configuration
All handler functions accept additional optional parameters:
target - Provide a local object to watch (instead of using get_it):
final myManager = UserManager();
registerHandler(
select: (UserManager m) => m.currentUser,
handler: (context, user, cancel) { /* ... */ },
target: myManager, // Use this local object, not get_it
);
// Or provide the listenable/stream/future directly without selector
registerHandler(
handler: (context, user, cancel) { /* ... */ },
target: myValueNotifier, // Watch this ValueNotifier directly
);Important
If target is used as the observable object (listenable/stream/future) and it changes during builds with allowObservableChange: false (the default), an exception will be thrown. Set allowObservableChange: true if the target observable needs to change between builds.
allowObservableChange - Controls selector caching behavior (default: false):
See Safety: Automatic Caching in Selector Functions for detailed explanation of this parameter.
executeImmediately - Execute handler on first build with current value (default: false):
registerHandler(
select: (DataManager m) => m.data,
handler: (context, value, cancel) { /* ... */ },
executeImmediately: true, // Handler called immediately with current value
);When true, the handler is called on the first build with the current value of the observed object, without waiting for a change. The handler then continues to execute on subsequent changes.
Handler vs Watch Decision Tree
Ask yourself: "Does this change need to update the UI?"
YES → Use watch():
class DecisionTreeWatch extends WatchingWidget {
@override
Widget build(BuildContext context) {
final todos = watchValue((TodoManager m) => m.todos);
return ListView(
children: todos.map((t) => Text(t.title)).toList(),
);
}
}NO (Should it call a function, navigate, show a toast, etc.) → Use registerHandler():
class DecisionTreeHandler extends WatchingWidget {
@override
Widget build(BuildContext context) {
registerHandler(
select: (TodoManager m) => m.createTodoCommand,
handler: (context, result, cancel) {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => Scaffold()),
);
},
);
return Container();
}
}Important: You cannot update local variables inside a handler that will be used in the build function outside the handler. Handlers don't trigger rebuilds, so any variable changes won't be reflected in the UI. If you need to update the UI, use watch() instead.
Common Mistakes
❌️ Using watch() for navigation
class MistakeBad extends WatchingWidget {
@override
Widget build(BuildContext context) {
// BAD - rebuilds entire widget just to navigate
final user = watchValue((UserManager m) => m.currentUser);
if (user != null) {
// Navigator.push(...); // Triggers unnecessary rebuild
}
return Container();
}
}✅ Use handler for navigation
class MistakeGood extends WatchingWidget {
@override
Widget build(BuildContext context) {
// GOOD - navigate without rebuild
registerHandler(
select: (UserManager m) => m.currentUser,
handler: (context, user, cancel) {
if (user != null) {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => Scaffold()),
);
}
},
);
return Container();
}
}What's Next?
Now you know when to rebuild (watch) vs when to run side effects (handlers). Next:
- Observing Commands - Comprehensive command_it integration
- Watch Ordering Rules - CRITICAL constraints
- Lifecycle Functions -
callOnce,createOnce, etc.
Key Takeaways
✅ watch() = Rebuild the widget ✅ registerHandler() = Side effect (navigation, toast, etc.) ✅ Handlers receive context, value, and cancel ✅ Use cancel() for one-time actions ✅ Combine watch and handlers in same widget ✅ Choose based on: "Does this need to update the UI?"
See Also
- Your First Watch Functions - Learn watch basics
- Observing Commands - command_it integration
- Watch Functions Reference - Complete API docs