Skip to content

Observando Commands con watch_it

Una de las combinaciones más poderosas en el ecosistema flutter_it es usar watch_it para observar commands de command_it. Los commands son objetos ValueListenable que exponen su estado (isRunning, value, errors) como propiedades ValueListenable, haciéndolos naturalmente observables por watch_it. Este patrón proporciona gestión de estado reactiva y declarativa para operaciones async con estados de carga automáticos, manejo de errores y actualizaciones de resultado.

Aprende Sobre Commands Primero

Si eres nuevo en command_it, empieza con la guía command_it Getting Started para entender cómo funcionan los commands.

¿Por Qué watch_it + command_it?

Los commands encapsulan operaciones async y rastrean su estado de ejecución (isRunning, value, errors). watch_it permite que tus widgets se reconstruyan reactivamente cuando estos estados cambian, creando una experiencia de usuario fluida sin gestión de estado manual.

Beneficios:

  • Estados de carga automáticos - No necesitas rastrear manualmente booleanos isLoading
  • Resultados reactivos - La UI se actualiza automáticamente cuando el command se completa
  • Manejo de errores incorporado - Los commands rastrean errores, watch_it los muestra
  • Separación limpia - Lógica de negocio en commands, lógica de UI en widgets
  • Sin boilerplate - Sin setState, sin StreamBuilder, sin listeners manuales

Observando un Command

Un patrón típico es observar tanto el resultado del command como su estado de ejecución como valores separados:

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

Puntos clave:

  • Observa el command mismo para obtener su valor (el resultado)
  • Observa command.isRunning para obtener el estado de ejecución
  • El widget se reconstruye automáticamente cuando cualquiera cambia
  • Los commands son objetos ValueListenable, por lo que funcionan perfectamente con watch_it
  • El botón se deshabilita durante la ejecución
  • El indicador de progreso se muestra mientras carga

Observando Errores de Command

Muestra errores observando la propiedad errors del command:

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

Patrones de manejo de errores:

  • Mostrar banner de error en la parte superior de la pantalla
  • Mostrar mensaje de error inline
  • Proporcionar botón de reintentar
  • Limpiar errores al reintentar

Usar Handlers para Efectos Secundarios

Mientras watch es para reconstruir UI, usa registerHandler para efectos secundarios como navegación o mostrar toasts:

Handler de Éxito

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

Efectos secundarios comunes de éxito:

  • Navegar a otra pantalla
  • Mostrar snackbar/toast de éxito
  • Disparar otro command
  • Registrar evento de analytics

Handler de Error

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

Efectos secundarios comunes de error:

  • Mostrar diálogo de error
  • Mostrar snackbar de error
  • Registrar error en reporte de crashes
  • Lógica de reintentar

Observando Resultados de Command

La propiedad results proporciona un objeto CommandResult conteniendo todo el estado del command en un lugar:

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

  • data - El valor actual del command
  • isRunning - Si el command se está ejecutando
  • hasError - Si ocurrió un error
  • error - El objeto de error si hay alguno
  • isSuccess - Si la ejecución tuvo éxito (!isRunning && !hasError)

La extensión .toWidget():

  • onData - Construir UI cuando los datos estén disponibles
  • onError - Construir UI cuando ocurre un error (muestra último resultado exitoso si está disponible)
  • whileRunning - Construir UI mientras el command se está ejecutando

Este patrón es ideal cuando necesitas manejar todos los estados del command de forma declarativa.

Otras Propiedades de Command

También puedes observar otras propiedades del command individualmente:

  • command.isRunning - Estado de ejecución
  • command.errors - Notificaciones de error
  • command.canRun - Si el command puede ejecutarse actualmente (combina !isRunning && !restriction)

Encadenar Commands

Usa handlers para encadenar commands juntos:

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

Patrones de encadenamiento:

  • Crear → Refrescar lista
  • Login → Navegar a home
  • Eliminar → Refrescar
  • Subir → Procesar → Notificar

Mejores Prácticas

1. Watch vs Handler

Usa watch cuando:

  • Necesites reconstruir el widget
  • Mostrar indicadores de carga
  • Mostrar resultados
  • Mostrar mensajes de error inline

Usa registerHandler cuando:

  • Navegación después de éxito
  • Mostrar diálogos/snackbars
  • Logging/analytics
  • Disparar otros commands
  • Cualquier efecto secundario que no requiere reconstrucción

2. No Hagas 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'),
    );
  }
}

¿Por qué? Los commands manejan async internamente. Solo llama run() y deja que watch_it actualice la UI reactivamente.

3. Observa Estado de Ejecución para Carga

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

Evita rastreo manual: No uses setState y flags booleanos. Deja que commands y watch_it manejen el estado reactivamente.

Patrones Comunes

Envío de Formulario

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

Ver También

Publicado bajo la Licencia MIT.