Efectos Secundarios con Handlers
Ya aprendiste las funciones watch() para reconstruir widgets. Pero ¿qué pasa con acciones que NO necesitan una reconstrucción, como llamar a una función, navegación, mostrar toasts, o logging?
Ahí es donde entran los handlers. Los handlers pueden reaccionar a cambios en ValueListenables, Listenables, Streams, y Futures sin disparar reconstrucciones de widget.
registerHandler - Lo Básico
registerHandler() ejecuta un callback cuando los datos cambian, pero no dispara una reconstrucción:
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'),
),
],
);
}
}El patrón:
select- Qué observar (comowatchValue)handler- Qué hacer cuando cambia- El handler recibe
context,value, y funcióncancel
Patrones Comunes de Handlers
Navegación en Éxito
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
}
}Llamar Funciones de Negocio
Uno de los usos más comunes de handlers es llamar comandos o métodos en objetos de negocio en respuesta a 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'),
),
],
);
}
}Puntos clave:
- El handler observa un trigger (envío de formulario, presión de botón, etc.)
- El handler llama comando/método en objeto de negocio
- El mismo widget puede opcionalmente observar el estado del comando (para indicadores de carga, etc.)
- Separación clara: handler dispara acción, watch muestra estado
Mostrar 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: Cuándo Usar Cada Uno
Usa watch() cuando necesites RECONSTRUIR el 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),
);
}
}Usa registerHandler() cuando necesites un EFECTO SECUNDARIO (sin reconstrucción):
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();
}
}Ejemplo Completo: Creación de Todo
Este ejemplo combina múltiples patrones de handler - navegación en éxito, manejo de errores, y observación de estado de carga:
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'),
),
],
),
),
);
}
}Este ejemplo demuestra:
- Observar resultado de comando para navegación
- Handler de error separado con UI de error
- Combinar
registerHandler()(efectos secundarios) conwatchValue()(estado de UI) - Usar
createOnce()para controllers
El Parámetro cancel
Todos los handlers reciben una función cancel. Llámala para dejar de reaccionar:
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();
}
}Caso de uso común: Acciones de una sola vez
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();
}
}Tipos de Handler
watch_it proporciona handlers especializados para diferentes tipos de datos:
registerHandler - Para 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 - Para 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'),
),
),
],
),
);
}
}Usar cuando:
- Observar un Stream
- Quieres reaccionar a cada evento
- No necesitas mostrar el valor (sin reconstrucción)
registerFutureHandler - Para 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) {},
),
);
},
),
),
],
),
);
}
}Usar cuando:
- Observar un Future
- Quieres ejecutar código cuando se complete
- No necesitas mostrar el valor
registerChangeNotifierHandler - Para 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,
),
),
],
),
),
);
}
}Usar cuando:
- Observar un
ChangeNotifier - Necesitas acceso al objeto notifier completo
- Quieres disparar acciones en cualquier cambio
Patrones Avanzados
Encadenar Acciones
Los handlers sobresalen en encadenar acciones - disparar una operación después de que otra se complete:
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)'),
),
],
);
}
}Puntos clave:
- El handler observa la completación del guardado
- El handler dispara recarga en otro servicio
- Patrón común: guardar → recargar lista, actualizar → refrescar datos
- Cada servicio permanece independiente
Manejo de Errores
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'),
),
),
],
),
);
}
}Acciones con Debounce
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();
}
}Configuración Opcional de Handler
Todas las funciones handler aceptan parámetros opcionales adicionales:
target - Proporciona un objeto local a observar (en lugar de usar get_it):
final myManager = UserManager();
registerHandler(
select: (UserManager m) => m.currentUser,
handler: (context, user, cancel) { /* ... */ },
target: myManager, // Usa este objeto local, no get_it
);
// O proporciona el listenable/stream/future directamente sin selector
registerHandler(
handler: (context, user, cancel) { /* ... */ },
target: myValueNotifier, // Observa este ValueNotifier directamente
);Importante
Si se usa target como el objeto observable (listenable/stream/future) y cambia durante construcciones con allowObservableChange: false (el predeterminado), se lanzará una excepción. Establece allowObservableChange: true si el observable target necesita cambiar entre construcciones.
allowObservableChange - Controla el comportamiento de caché del selector (predeterminado: false):
Ver Safety: Automatic Caching in Selector Functions para explicación detallada de este parámetro.
executeImmediately - Ejecuta handler en la primera construcción con el valor actual (predeterminado: false):
registerHandler(
select: (DataManager m) => m.data,
handler: (context, value, cancel) { /* ... */ },
executeImmediately: true, // Handler llamado inmediatamente con valor actual
);Cuando es true, el handler se llama en la primera construcción con el valor actual del objeto observado, sin esperar un cambio. El handler luego continúa ejecutándose en cambios subsiguientes.
Árbol de Decisión Handler vs Watch
Pregúntate: "¿Este cambio necesita actualizar la UI?"
Sí → Usa 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 (¿Debería llamar a una función, navegar, mostrar un toast, etc?) → Usa 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();
}
}Importante: No puedes actualizar variables locales dentro de un handler que se usarán en la función build fuera del handler. Los handlers no disparan reconstrucciones, por lo que cualquier cambio de variable no se reflejará en la UI. Si necesitas actualizar la UI, usa watch() en su lugar.
Errores Comunes
❌️ Usar watch() para navegación
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();
}
}✅ Usar handler para navegación
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();
}
}¿Qué Sigue?
Ahora sabes cuándo reconstruir (watch) vs cuándo ejecutar efectos secundarios (handlers). Siguiente:
- Observing Commands - Integración comprehensiva con command_it
- Watch Ordering Rules - Restricciones CRÍTICAS
- Lifecycle Functions -
callOnce,createOnce, etc.
Puntos Clave
✅ watch() = Reconstruir el widget ✅ registerHandler() = Efecto secundario (navegación, toast, etc.) ✅ Los handlers reciben context, value, y cancel ✅ Usa cancel() para acciones de una sola vez ✅ Combina watch y handlers en el mismo widget ✅ Elige basándote en: "¿Esto necesita actualizar la UI?"
Ver También
- Your First Watch Functions - Aprende lo básico de watch
- Observing Commands - Integración con command_it
- Watch Functions Reference - Docs completos de API