Skip to content

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:

dart
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:

  1. select - Qué observar (como watchValue)
  2. handler - Qué hacer cuando cambia
  3. El handler recibe context, value, y función cancel

Patrones Comunes de Handlers

Navegación en Éxito
dart
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:

dart
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
dart
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:

dart
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):

dart
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:

dart
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) con watchValue() (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:

dart
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

dart
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

dart
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

dart
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

dart
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

dart
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:

dart
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
dart
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
dart
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):

dart
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):

dart
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?"

→ Usa watch():

dart
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():

dart
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

dart
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

dart
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:

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

Publicado bajo la Licencia MIT.