Skip to content

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:

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'),
        ),
      ],
    );
  }
}

The pattern:

  1. select - What to watch (like watchValue)
  2. handler - What to do when it changes
  3. Handler receives context, value, and cancel function

Common Handler Patterns

Navigation on Success
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
  }
}
Calling Business Functions

One of the most common uses of handlers is to call commands or methods on business objects in response to 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'),
        ),
      ],
    );
  }
}

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
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: When to Use Each

Use watch() when you need to REBUILD the 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),
    );
  }
}

Use registerHandler() when you need a SIDE EFFECT (no rebuild):

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();
  }
}

Complete Example: Todo Creation

This example combines multiple handler patterns - navigation on success, error handling, and watching loading state:

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'),
            ),
          ],
        ),
      ),
    );
  }
}

This example demonstrates:

  • Watching command result for navigation
  • Separate error handler with error UI
  • Combining registerHandler() (side effects) with watchValue() (UI state)
  • Using createOnce() for controllers

The cancel Parameter

All handlers receive a cancel function. Call it to stop reacting:

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();
  }
}

Common use case: One-time actions

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();
  }
}

Handler Types

watch_it provides specialized handlers for different data types:

registerHandler - For 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 - For 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'),
            ),
          ),
        ],
      ),
    );
  }
}

Use when:

  • Watching a Stream
  • Want to react to each event
  • Don't need to display the value (no rebuild)

registerFutureHandler - For 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) {},
                        ),
                      );
                    },
                  ),
          ),
        ],
      ),
    );
  }
}

Use when:

  • Watching a Future
  • Want to run code when it completes
  • Don't need to display the value

registerChangeNotifierHandler - For 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,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

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:

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)'),
        ),
      ],
    );
  }
}

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
dart
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
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();
  }
}

Optional Handler Configuration

All handler functions accept additional optional parameters:

target - Provide a local object to watch (instead of using get_it):

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

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

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 (Should it call a function, navigate, show a toast, etc.) → Use 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();
  }
}

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

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();
  }
}

✅ Use handler for navigation

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();
  }
}

What's Next?

Now you know when to rebuild (watch) vs when to run side effects (handlers). Next:

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

Released under the MIT License.