Mejores Prácticas
Patrones listos para producción, anti-patrones, y guías para usar command_it efectivamente.
Cuándo Usar Commands
✅ Usa Commands Para
Operaciones async con feedback de UI:
late final loadDataCommand = Command.createAsyncNoParam<List<Data>>(
() => api.fetchData(),
initialValue: [],
);
// Automatic isRunning, error handling, UI integrationOperaciones que pueden fallar:
late final saveCommand = Command.createAsyncNoResult<Data>(
(data) => api.save(data),
errorFilter: PredicatesErrorFilter([
(e, _) => errorFilter<ApiException>(e, ErrorReaction.localHandler),
]),
);Acciones disparadas por usuario:
late final submitCommand = Command.createAsyncNoResult<FormData>(
(data) => api.submit(data),
restriction: formValid.map((valid) => !valid),
);Operaciones que necesitan tracking de estado:
- Estados de carga de botones
- Pull-to-refresh
- Envío de formularios
- Peticiones de red
- I/O de archivos
✅ Usa Commands Sync para Input con Operadores
Cuando necesitas aplicar operadores (debounce, map, where) a input de usuario antes de disparar otras operaciones, ver Encadenamiento de Commands para patrones usando pipeToCommand y operadores de listen_it.
❌️️ No Uses Commands Para
Simples getters/setters (sin operadores o encadenamiento):
// ignore_for_file: unused_field, unused_local_variable
String _name = '';
// ❌ Overkill
late final getNameCommand = Command.createSyncNoParam<String>(
() => _name,
initialValue: '',
);
// ✅ Just use a ValueNotifier
final name = ValueNotifier<String>('');Cómputos puros sin efectos secundarios:
// ❌ Unnecessary
late final calculateCommand = Command.createSync<int, int>(
(n) => n * 2,
initialValue: 0,
);
// ✅ Just use a function
int calculate(int n) => n * 2;Cambios de estado inmediatos:
bool _enabled = false;
// ❌ Overcomplicated
late final toggleCommand = Command.createSyncNoParam<bool>(
() => !_enabled,
initialValue: false,
);
// ✅ Use ValueNotifier directly
final enabled = ValueNotifier<bool>(false);
void toggle() => enabled.value = !enabled.value;Patrones de Organización
Patrón 1: Commands en Managers
class TodoManager {
final ApiClient api;
final Database db;
TodoManager(this.api, this.db);
// Group related commands
late final loadTodosCommand = Command.createAsyncNoParam<List<Todo>>(
() => api.fetchTodos(),
initialValue: [],
);
late final addTodoCommand = Command.createAsyncNoResult<Todo>(
(todo) async {
await api.saveTodo(todo);
loadTodosCommand.run(); // Reload after add
},
);
late final deleteTodoCommand = Command.createAsyncNoResult<String>(
(id) async {
await api.deleteTodo(id);
loadTodosCommand.run(); // Reload after delete
},
restriction: loadTodosCommand.isRunningSync, // Can't delete while loading
);
void dispose() {
loadTodosCommand.dispose();
addTodoCommand.dispose();
deleteTodoCommand.dispose();
}
}Beneficios:
- Lógica de negocio centralizada
- Fácil testing
- Reutilizable entre widgets
- Propiedad clara
Patrón 2: Organización Basada en Features
// features/authentication/auth_manager.dart
class AuthManager {
final ApiClient _api = ApiClient();
// Expose login state as a simple ValueNotifier for restrictions
final isLoggedIn = ValueNotifier<bool>(false);
late final loginCommand = Command.createAsync<LoginCredentials, User>(
(data) async {
final user = await _api.login(data.username, data.password);
isLoggedIn.value = true;
return user;
},
initialValue: User.empty(),
);
late final logoutCommand = Command.createAsyncNoParamNoResult(
() async {
await _api.logout();
isLoggedIn.value = false;
},
);
}
// features/profile/profile_manager.dart
class ProfileManager {
final AuthManager auth;
final ApiClient _api = ApiClient();
ProfileManager(this.auth);
late final loadProfileCommand = Command.createAsyncNoParam<Profile>(
() => _api.loadProfile(),
initialValue: Profile.empty(),
// restriction: true = disabled, so negate isLoggedIn
restriction: auth.isLoggedIn.map((logged) => !logged),
);
}Patrón 3: Commands en Data Proxies
Los Commands también pueden vivir en objetos de datos que gestionan sus propias operaciones async. Esto es útil cuando cada item de datos necesita estado de carga independiente:
/// Data proxy that owns commands for lazy loading.
/// Each instance manages its own async operations.
class PodcastProxy {
PodcastProxy({required this.feedUrl, required PodcastService podcastService})
: _podcastService = podcastService {
// Each proxy owns its fetch command
fetchEpisodesCommand = Command.createAsyncNoParam<List<Episode>>(
() async {
if (_episodes != null) return _episodes!;
_episodes = await _podcastService.fetchEpisodes(feedUrl);
return _episodes!;
},
initialValue: [],
);
}
final String feedUrl;
final PodcastService _podcastService;
List<Episode>? _episodes;
late final Command<void, List<Episode>> fetchEpisodesCommand;
/// Fetches episodes if not cached, then starts playback.
late final playEpisodesCommand = Command.createAsyncNoResult<int>((
startIndex,
) async {
if (_episodes == null) {
await fetchEpisodesCommand.runAsync();
}
if (_episodes != null && _episodes!.isNotEmpty) {
// Start playback at index...
}
});
List<Episode> get episodes => _episodes ?? [];
}
/// Manager creates and caches proxies
class PodcastManager {
PodcastManager(this._podcastService);
final PodcastService _podcastService;
final _proxyCache = <String, PodcastProxy>{};
PodcastProxy getOrCreateProxy(String feedUrl) {
return _proxyCache.putIfAbsent(
feedUrl,
() => PodcastProxy(feedUrl: feedUrl, podcastService: _podcastService),
);
}
}Beneficios:
- Cada item tiene estado de carga/error independiente
- La lógica de caching vive con los datos
- La UI puede observar estado de item individual
- El manager se mantiene simple (solo crea/cachea proxies)
Cuándo Usar runAsync()
Como se explica en Fundamentos de Command, el patrón central de command es dispara-y-olvida: llama run() y deja que tu UI observe cambios de estado reactivamente. Sin embargo, hay casos legítimos donde usar runAsync() es apropiado y más expresivo que las alternativas.
✅ Usa runAsync() Para Flujos de Trabajo Secuenciales
Cuando los commands son parte de un flujo de trabajo async más grande mezclado con otras operaciones async:
class PaymentManager {
late final validatePaymentCommand = Command.createAsync<PaymentInfo, bool>(
(info) => api.validatePayment(info),
initialValue: false,
);
late final processPaymentCommand = Command.createAsync<PaymentInfo, Receipt>(
(info) => api.processPayment(info),
initialValue: Receipt.empty(),
);
// Complex async workflow
Future<Receipt> completeCheckout(Cart cart, PaymentInfo payment) async {
// Step 1: Validate inventory (not a command, just async call)
final available = await api.checkInventory(cart.items);
if (!available) throw InsufficientInventoryException();
// Step 2: Validate payment (command)
final isValid = await validatePaymentCommand.runAsync(payment);
if (!isValid) throw InvalidPaymentException();
// Step 3: Process payment (command)
final receipt = await processPaymentCommand.runAsync(payment);
// Step 4: Update inventory (not a command)
await api.updateInventory(cart.items);
return receipt;
}
}¿Por qué runAsync() aquí? El command es parte de una función async más grande que mezcla ejecución de commands con llamadas async regulares. Usar runAsync() mantiene el código lineal y legible.
✅ Usa runAsync() Para APIs que Requieren Futures
Cuando interactúas con APIs que requieren un Future:
class RefreshExample extends StatelessWidget {
final Command<void, List<Data>> updateCommand;
const RefreshExample({super.key, required this.updateCommand});
@override
Widget build(BuildContext context) {
// RefreshIndicator requires Future<void>
return RefreshIndicator(
onRefresh: () => updateCommand.runAsync(),
child: ListView(),
);
}
}❌️ No Uses runAsync() para Actualizaciones de UI Simples
class BadExample extends StatelessWidget {
final Command<void, List<Data>> loadDataCommand;
const BadExample({super.key, required this.loadDataCommand});
@override
Widget build(BuildContext context) {
return Column(
children: [
// ❌ BAD: Blocking UI thread waiting for result
ElevatedButton(
onPressed: () async {
final result = await loadDataCommand.runAsync();
// Do nothing with result - just waiting
},
child: Text('Load Bad'),
),
// ✅ GOOD: Fire and forget, let UI observe
ElevatedButton(
onPressed: loadDataCommand.run,
child: Text('Load Good'),
),
],
);
}
}Resumen
Usa runAsync() cuando:
- ✅ Los commands son parte de un flujo de trabajo async más grande
- ✅ Una API requiere que se retorne un Future
- ✅ El flujo secuencial es más claro con
awaitque con.listen()
No uses runAsync() cuando:
- ❌️ Disparando commands desde interacciones de UI (usa
run()) - ❌️ Solo quieres observar resultados (usa
watchValue()oValueListenableBuilder) - ❌️ El async/await no agrega valor sobre dispara-y-olvida
Mejores Prácticas de Rendimiento
Debounce de Input de Texto
class SearchManagerDebounce {
late final searchTextCommand = Command.createSync<String, String>(
(text) => text,
initialValue: '',
);
late final searchCommand = Command.createAsync<String, List<Result>>(
(query) => api.search(query),
initialValue: [],
);
SearchManagerDebounce() {
// Debounce text changes
searchTextCommand.debounce(Duration(milliseconds: 300)).listen((text, _) {
if (text.isNotEmpty) {
searchCommand(text);
}
});
}
}Dispose de Commands Apropiadamente
class DataManager {
late final command = Command.createAsyncNoParam<Data>(
() => api.fetchData().then((list) => list.first),
initialValue: Data.empty(),
);
// ✅ Always dispose in cleanup
void dispose() {
command.dispose();
}
}
// With StatefulWidget
class MyWidget extends StatefulWidget {
const MyWidget({super.key});
@override
State<MyWidget> createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget> {
final manager = DataManager();
@override
void dispose() {
manager.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => Container();
}
// With get_it scopes
void registerWithGetIt() {
getIt.registerLazySingleton<DataManager>(
() => DataManager(),
dispose: (manager) => manager.dispose(),
);
}Evitar Rebuilds Innecesarios
class RebuildExample extends StatelessWidget {
final Command<void, String> command;
const RebuildExample({super.key, required this.command});
@override
Widget build(BuildContext context) {
return Column(
children: [
// ❌ Rebuilds on every command property change
ValueListenableBuilder(
valueListenable: command.results,
builder: (context, result, _) => Text(result.data?.toString() ?? ''),
),
// ✅ Only rebuilds when value changes
ValueListenableBuilder(
valueListenable: command,
builder: (context, data, _) => Text(data.toString()),
),
],
);
}
}Mejores Prácticas de Restricciones
Usa isRunningSync para Dependencias de Commands
late final loadCommand = Command.createAsyncNoParam<List<Data>>(
() => api.fetchData(),
initialValue: [],
);
// ✅ Correct: Synchronous restriction
late final saveCommandGood = Command.createAsyncNoResult<Data>(
(data) => api.save(data),
restriction: loadCommand.isRunningSync, // Prevents race conditions
);
// ❌ Wrong: Async update can cause races
late final saveCommandBad = Command.createAsyncNoResult<Data>(
(data) => api.save(data),
restriction: loadCommand.isRunning, // Race condition possible!
);La Lógica de Restricción Está Invertida
final isLoggedIn = ValueNotifier<bool>(false);
// ❌ Common mistake: Restriction logic backwards
late final commandBad = Command.createAsyncNoParam<Data>(
() => api.fetchData().then((list) => list.first),
initialValue: Data.empty(),
restriction: isLoggedIn, // WRONG: Disabled when logged in!
);
// ✅ Correct: Negate the condition
late final commandGood = Command.createAsyncNoParam<Data>(
() => api.fetchData().then((list) => list.first),
initialValue: Data.empty(),
restriction:
isLoggedIn.map((logged) => !logged), // Disabled when NOT logged in
);Anti-Patrones Comunes
❌️️ No Escuchar Errores
// ❌ BAD: Errors go nowhere
late final commandNoListener = Command.createAsyncNoParam<Data>(
() => api.fetchData().then((list) => list.first),
initialValue: Data.empty(),
errorFilter: const LocalErrorFilter(),
);
// No error listener! Assertions in debug mode
// ✅ GOOD: Always listen to errors when using localHandler
void setupGoodErrorListener() {
commandNoListener.errors.listen((error, _) {
if (error != null) showError(error.error);
});
}
void showError(Object error) {
debugPrint('Error: $error');
}❌️️ Try/Catch Dentro de Commands
No uses try/catch dentro de funciones de commands - derrota el sistema de manejo de errores de command_it:
// ❌ BAD: try/catch inside command function
class DataManagerBad {
late final loadCommand = Command.createAsyncNoParam<Data>(
() async {
try {
final list = await api.fetchData();
return list.first;
} catch (e) {
// Manual error handling defeats command_it's error system
debugPrint('Error: $e');
rethrow;
}
},
initialValue: Data.empty(),
);
}
// ✅ GOOD: Let command handle errors, use ..errors.listen()
class DataManagerGood {
late final loadCommand = Command.createAsyncNoParam<Data>(
() async {
final list = await api.fetchData();
return list.first;
},
initialValue: Data.empty(),
)..errors.listen((error, _) {
if (error != null) {
debugPrint('Error: ${error.error}');
}
});
}Ver También
- Fundamentos de Command — Primeros pasos
- Manejo de Errores (Error Handling) — Gestión de errores
- Testing — Patrones de testing
- Observando Commands con
watch_it— Patrones de UI reactiva