Skip to content

Observing Commands with watch_it

One of the most powerful combinations in the flutter_it ecosystem is using watch_it to observe command_it commands. Commands are ValueListenable objects that expose their state (isRunning, value, errors) as ValueListenable properties, making them naturally observable by watch_it. This pattern provides reactive, declarative state management for async operations with automatic loading states, error handling, and result updates.

Learn about Commands First

If you're new to command_it, start with the command_it Getting Started guide to understand how commands work.

Why watch_it + command_it?

Commands encapsulate async operations and track their execution state (isRunning, value, errors). watch_it allows your widgets to reactively rebuild when these states change, creating a seamless user experience without manual state management.

Benefits:

  • Automatic loading states - No need to manually track isLoading booleans
  • Reactive results - UI updates automatically when command completes
  • Built-in error handling - Commands track errors, watch_it displays them
  • Clean separation - Business logic in commands, UI logic in widgets
  • No boilerplate - No setState, no StreamBuilder, no manual listeners

Watching a Command

A typical pattern is to watch both the command's result and its execution state as separate values:

dart
class TodoLoadingWidget extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    // Load data on first build
    callOnce((_) {
      di<TodoManager>().fetchTodosCommand.run();
    });

    // Watch command's isRunning property to show loading state
    final isLoading =
        watchValue((TodoManager m) => m.fetchTodosCommand.isRunning);

    // Watch the command itself to get its value (List<TodoModel>)
    // Commands are ValueListenables, so watching them gives you their current value
    final todos = watchValue((TodoManager m) => m.fetchTodosCommand);

    return Scaffold(
      appBar: AppBar(title: const Text('Watch Command - Loading State')),
      body: Column(
        children: [
          // Show loading indicator when command is executing
          if (isLoading)
            const LinearProgressIndicator()
          else
            const SizedBox(height: 4),
          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(
              // Disable button while loading
              onPressed: isLoading
                  ? null
                  : () => di<TodoManager>().fetchTodosCommand.run(),
              child: const Text('Refresh'),
            ),
          ),
        ],
      ),
    );
  }
}

Key points:

  • Watch the command itself to get its value (the result)
  • Watch command.isRunning to get the execution state
  • Widget rebuilds automatically when either changes
  • Commands are ValueListenable objects, so they work seamlessly with watch_it
  • Button disables during execution
  • Progress indicator shows while loading

Watching Command Errors

Display errors by watching the command's errors property:

dart
class CommandErrorWidget extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    callOnce((_) {
      di<TodoManager>().fetchTodosCommand.run();
    });

    // Watch command's errors property to display error messages
    final error = watchValue((TodoManager m) => m.fetchTodosCommand.errors);
    final isLoading =
        watchValue((TodoManager m) => m.fetchTodosCommand.isRunning);
    final todos = watchValue((TodoManager m) => m.fetchTodosCommand);

    return Scaffold(
      appBar: AppBar(title: const Text('Watch Command - Errors')),
      body: Column(
        children: [
          // Display error banner when command fails
          if (error != null)
            Container(
              color: Colors.red.shade100,
              padding: const EdgeInsets.all(16),
              child: Row(
                children: [
                  const Icon(Icons.error, color: Colors.red),
                  const SizedBox(width: 8),
                  Expanded(
                    child: Text(
                      'Error: ${error.toString()}',
                      style: const TextStyle(color: Colors.red),
                    ),
                  ),
                  IconButton(
                    icon: const Icon(Icons.close),
                    onPressed: () {
                      // Clear error by executing again
                      di<TodoManager>().fetchTodosCommand.run();
                    },
                  ),
                ],
              ),
            ),
          if (isLoading)
            const LinearProgressIndicator()
          else
            const SizedBox(height: 4),
          Expanded(
            child: todos.isEmpty
                ? Center(
                    child: Column(
                      mainAxisAlignment: MainAxisAlignment.center,
                      children: [
                        if (error != null) ...[
                          const Icon(Icons.error_outline,
                              size: 64, color: Colors.red),
                          const SizedBox(height: 16),
                          const Text('Failed to load todos'),
                          const SizedBox(height: 16),
                          ElevatedButton(
                            onPressed: () =>
                                di<TodoManager>().fetchTodosCommand.run(),
                            child: const Text('Retry'),
                          ),
                        ] else if (isLoading)
                          const CircularProgressIndicator()
                        else
                          const 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),
                      );
                    },
                  ),
          ),
        ],
      ),
    );
  }
}

Error handling patterns:

  • Show error banner at top of screen
  • Display error message inline
  • Provide retry button
  • Clear errors on retry

Using Handlers for Side Effects

While watch is for rebuilding UI, use registerHandler for side effects like navigation or showing toasts:

Success Handler

dart
class CreateTodoWithHandlerWidget extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    final titleController = createOnce(() => TextEditingController());
    final descController = createOnce(() => TextEditingController());

    // Use registerHandler to handle successful command completion
    // This is perfect for navigation, showing success messages, etc.
    registerHandler(
      select: (TodoManager m) => m.createTodoCommand,
      handler: (context, result, _) {
        // Show success snackbar
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text('Created: ${result!.title}'),
            backgroundColor: Colors.green,
          ),
        );
        // Navigate back with result
        Navigator.of(context).pop(result);
      },
    );

    final isCreating =
        watchValue((TodoManager m) => m.createTodoCommand.isRunning);

    return Scaffold(
      appBar: AppBar(title: const Text('Command Handler - Success')),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: [
            const Text(
              'This example uses registerHandler to navigate on success',
              style: TextStyle(fontStyle: FontStyle.italic),
            ),
            const SizedBox(height: 24),
            TextField(
              controller: titleController,
              decoration: const InputDecoration(
                labelText: 'Title',
                border: OutlineInputBorder(),
              ),
            ),
            const SizedBox(height: 16),
            TextField(
              controller: descController,
              decoration: const InputDecoration(
                labelText: 'Description',
                border: OutlineInputBorder(),
              ),
              maxLines: 3,
            ),
            const SizedBox(height: 24),
            SizedBox(
              width: double.infinity,
              child: ElevatedButton(
                onPressed: isCreating
                    ? null
                    : () {
                        final params = CreateTodoParams(
                          title: titleController.text,
                          description: descController.text,
                        );
                        di<TodoManager>().createTodoCommand.run(params);
                      },
                child: isCreating
                    ? const SizedBox(
                        height: 20,
                        width: 20,
                        child: CircularProgressIndicator(strokeWidth: 2),
                      )
                    : const Text('Create Todo'),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Common success side effects:

  • Navigate to another screen
  • Show success snackbar/toast
  • Trigger another command
  • Log analytics event

Error Handler

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

Common error side effects:

  • Show error dialog
  • Show error snackbar
  • Log error to crash reporting
  • Retry logic

Handler Lifecycle

If you rely on your handler reacting to all state changes, ensure the widget where the handler is registered isn't destroyed and rebuilt during command execution.

Common pitfall: A button that registers a handler and calls a command, but the parent widget rebuilds on an onHover event - this destroys and recreates the button (and its handler), causing missed state changes.

Solution: Move the handler to a parent widget that will live for the entire duration of the command execution.

Note: This doesn't apply to watch functions - their results are only used in the same build function, so widget rebuilds don't cause issues.

Watching Command Results

The results property provides a CommandResult object containing all command state in one place:

dart
class CommandResultsWidget extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    callOnce((_) => di<TodoManager>().fetchTodosCommand.run());

    // Watch the command's results property which contains all state:
    // - data: The command's value
    // - isRunning: Execution state
    // - hasError: Whether an error occurred
    // - error: The error if any
    return watchValue(
      (TodoManager m) => m.fetchTodosCommand.results,
    ).toWidget(
      onData: (todos, param) => ListView.builder(
        itemCount: todos.length,
        itemBuilder: (context, index) => ListTile(
          title: Text(todos[index].title),
          subtitle: Text(todos[index].description),
        ),
      ),
      onError: (error, lastResult, param) => Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Icon(Icons.error_outline, size: 64, color: Colors.red),
            const SizedBox(height: 16),
            Text('Error: $error'),
            const SizedBox(height: 16),
            ElevatedButton(
              onPressed: () => di<TodoManager>().fetchTodosCommand.run(),
              child: const Text('Retry'),
            ),
          ],
        ),
      ),
      whileRunning: (lastResult, param) => const Center(
        child: CircularProgressIndicator(),
      ),
    );
  }
}

CommandResult contains:

  • data - The command's current value
  • isRunning - Whether the command is executing
  • hasError - Whether an error occurred
  • error - The error object if any
  • isSuccess - Whether execution succeeded (!isRunning && !hasError)

The .toWidget() extension:

  • onData - Build UI when data is available
  • onError - Build UI when an error occurs (shows last successful result if available)
  • whileRunning - Build UI while command is executing

This pattern is ideal when you need to handle all command states in a declarative way.

Other Command Properties

You can also watch other command properties individually:

  • command.isRunning - Execution state
  • command.errors - Error notifications
  • command.canRun - Whether the command can currently execute (combines !isRunning && !restriction)

Chaining Commands

Use handlers to chain commands together:

dart
class CommandChainingWidget extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    callOnce((_) {
      di<TodoManager>().fetchTodosCommand.run();
    });

    // Use registerHandler to chain commands
    // When create succeeds, automatically refresh the list
    registerHandler(
      select: (TodoManager m) => m.createTodoCommand,
      handler: (context, result, _) {
        // Chain: after creating, fetch the updated list
        di<TodoManager>().fetchTodosCommand.run();

        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text('Created "${result!.title}" and refreshed list'),
            backgroundColor: Colors.green,
          ),
        );
      },
    );

    final isCreating =
        watchValue((TodoManager m) => m.createTodoCommand.isRunning);
    final isFetching =
        watchValue((TodoManager m) => m.fetchTodosCommand.isRunning);
    final todos = watchValue((TodoManager m) => m.fetchTodosCommand);

    return Scaffold(
      appBar: AppBar(title: const Text('Command Chaining')),
      body: Column(
        children: [
          Container(
            padding: const EdgeInsets.all(16),
            color: Colors.blue.shade50,
            child: const Text(
              'This example chains commands: Create → Refresh List',
              style: TextStyle(fontStyle: FontStyle.italic),
            ),
          ),
          if (isFetching)
            const LinearProgressIndicator()
          else
            const SizedBox(height: 4),
          Expanded(
            child: todos.isEmpty
                ? Center(
                    child: Column(
                      mainAxisAlignment: MainAxisAlignment.center,
                      children: [
                        if (isFetching)
                          const CircularProgressIndicator()
                        else
                          const 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),
                        trailing: IconButton(
                          icon: const Icon(Icons.delete),
                          onPressed: () {
                            di<TodoManager>().deleteTodoCommand.run(todo.id);
                            // Chain: after deleting, refresh
                            Future.delayed(
                              const Duration(milliseconds: 100),
                              () => di<TodoManager>().fetchTodosCommand.run(),
                            );
                          },
                        ),
                      );
                    },
                  ),
          ),
          Padding(
            padding: const EdgeInsets.all(16.0),
            child: Column(
              children: [
                SizedBox(
                  width: double.infinity,
                  child: ElevatedButton(
                    onPressed: isCreating
                        ? null
                        : () {
                            final params = CreateTodoParams(
                              title: 'New Todo ${todos.length + 1}',
                              description: 'Created at ${DateTime.now()}',
                            );
                            di<TodoManager>().createTodoCommand.run(params);
                          },
                    child: isCreating
                        ? const Row(
                            mainAxisAlignment: MainAxisAlignment.center,
                            children: [
                              SizedBox(
                                height: 20,
                                width: 20,
                                child:
                                    CircularProgressIndicator(strokeWidth: 2),
                              ),
                              SizedBox(width: 12),
                              Text('Creating & Refreshing...'),
                            ],
                          )
                        : const Text('Create Todo (will auto-refresh)'),
                  ),
                ),
                const SizedBox(height: 8),
                SizedBox(
                  width: double.infinity,
                  child: OutlinedButton(
                    onPressed: isFetching
                        ? null
                        : () => di<TodoManager>().fetchTodosCommand.run(),
                    child: const Text('Manual Refresh'),
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

Chaining patterns:

  • Create → Refresh list
  • Login → Navigate to home
  • Delete → Refresh
  • Upload → Process → Notify

Best Practices

1. Watch vs Handler

Use watch when:

  • You need to rebuild the widget
  • Showing loading indicators
  • Displaying results
  • Showing error messages inline

Use registerHandler when:

  • Navigation after success
  • Showing dialogs/snackbars
  • Logging/analytics
  • Triggering other commands
  • Any side effect that doesn't require rebuild

2. Don't Await run()

dart
// ✓ GOOD - Non-blocking, UI stays responsive
class DontAwaitExecuteGood extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () => di<TodoManager>().createTodoCommand.run(
            CreateTodoParams(title: 'New todo', description: 'Description'),
          ),
      child: Text('Submit'),
    );
  }
}
dart
// ❌ BAD - Blocks UI thread
class DontAwaitExecuteBad extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () async {
        await di<TodoManager>().createTodoCommand.runAsync(
              CreateTodoParams(title: 'New todo', description: 'Description'),
            );
      },
      child: Text('Submit'),
    );
  }
}

Why? Commands handle async internally. Just call run() and let watch_it update the UI reactively.

3. Watch Execution State for Loading

dart
// ✓ GOOD - Watch isRunning
class WatchExecutionStateGood extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    final command = createOnce(() => Command());
    final isLoading = watch(command.isRunning).value;

    if (isLoading) {
      return CircularProgressIndicator();
    }

    return Container();
  }
}

Avoid manual tracking: Don't use setState and boolean flags. Let commands and watch_it handle state reactively.

Common Patterns

Form Submission

dart
@override
Widget build(BuildContext context) {
  final isSubmitting = watchValue((Manager m) => m.submitCommand.isRunning);
  final canSubmit = formKey.currentState?.validate() ?? false;

  return ElevatedButton(
    onPressed: canSubmit && !isSubmitting
        ? () => di<Manager>().submitCommand.run()
        : null,
    child: isSubmitting
        ? const CircularProgressIndicator()
        : const Text('Submit'),
  );
}

Pull to Refresh

dart
// Pull to refresh pattern
class PullToRefreshPattern extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    final todos = watchValue((TodoManager m) => m.fetchTodosCommand);

    return RefreshIndicator(
      onRefresh: di<TodoManager>().fetchTodosCommand.runAsync,
      child: todos.isEmpty
          ? ListView(
              children: const [
                Center(child: Text('No todos - pull to refresh')),
              ],
            )
          : ListView.builder(
              itemCount: todos.length,
              itemBuilder: (context, index) => ListTile(
                title: Text(todos[index].title),
              ),
            ),
    );
  }
}

See Also

Released under the MIT License.