command-it-expert
command_it Expert - Command Pattern with Reactive States
What: Wrap functions as command objects with automatic loading/error/result states. Built on listen_it.
CRITICAL RULES
- Use
run()to execute commands, NOTexecute()(deprecated) - Sync commands ASSERT on
isRunningaccess - use async commands for loading states restriction.value == truemeans command is DISABLED (cannot run)- Factory constructors with
TResultrequireinitialValueparameter - Error filters return
ErrorReactionenum, NOT bool
Factory Constructors
Choose the right one based on parameter/result combinations:
// ASYNC - Most common
Command.createAsyncNoParamNoResult(() async { ... });
Command.createAsyncNoResult<TParam>((param) async { ... });
Command.createAsyncNoParam<TResult>(() async { ... }, initialValue: defaultValue);
Command.createAsync<TParam, TResult>((param) async { ... }, initialValue: defaultValue);
// SYNC - No isRunning support
Command.createSyncNoParamNoResult(() { ... });
Command.createSyncNoResult<TParam>((param) { ... });
Command.createSyncNoParam<TResult>(() { ... }, initialValue: defaultValue);
Command.createSync<TParam, TResult>((param) { ... }, initialValue: defaultValue);
// UNDOABLE - With undo stack
Command.createUndoableNoResult<TParam, TUndoState>(
(param, undoStack) async { undoStack.push(currentState); ... },
undo: (undoStack, error) async { final prev = undoStack.pop(); restore(prev); },
);
// WITH PROGRESS - Progress tracking
Command.createAsyncNoParamWithProgress<TResult>(
(handle) async {
handle.updateProgress(0.5);
handle.updateStatusMessage('Loading...');
if (handle.isCanceled.value) return defaultValue;
...
},
initialValue: defaultValue,
);
Execution
command.run(); // Fire and forget (returns void)
command.run(param); // With parameter
command(param); // Callable class syntax (alias for run)
final result = await command.runAsync(param); // Await result (async commands only)
Observable Properties (all ValueListenable)
command.isRunning // ValueListenable<bool> - ASYNC ONLY (asserts on sync), use for UI
command.isRunningSync // ValueListenable<bool> - ONLY for restrictions, NOT for UI updates
command.canRun // ValueListenable<bool> - !restriction && !isRunning
command.errors // ValueListenable<CommandError<TParam>?>
command.errorsDynamic // ValueListenable<CommandError<dynamic>?>
command.results // ValueListenable<CommandResult<TParam?, TResult>>
// WithProgress commands only:
command.progress // ValueListenable<double> - 0.0 to 1.0
command.statusMessage // ValueListenable<String?>
command.isCanceled // ValueListenable<bool>
CommandResult Properties
final result = command.results.value;
result.data // TResult? - the return value
result.error // Object? - exception if failed
result.isRunning // bool
result.paramData // TParam? - parameter passed to command
result.hasError // bool
result.hasData // bool
result.isSuccess // bool
result.stackTrace // StackTrace?
Restrictions
restriction takes ValueListenable<bool> - when true, command CANNOT run:
// Disable command while another is running
final saveCommand = Command.createAsyncNoParamNoResult(
() async { ... },
restriction: loadCommand.isRunning, // Can't save while loading
);
// Custom restriction
final isOffline = ValueNotifier<bool>(false);
final fetchCommand = Command.createAsyncNoParamNoResult(
() async { ... },
restriction: isOffline, // Can't fetch when offline
);
// ifRestrictedRunInstead - alternative action when restricted
final cmd = Command.createAsyncNoParamNoResult(
() async { ... },
restriction: someCondition,
ifRestrictedRunInstead: () => showToast('Cannot run now'),
);
Error Handling
ErrorFilter returns ErrorReaction enum:
enum ErrorReaction {
none, // Swallow error
throwException, // Rethrow
globalHandler, // Only global handler
localHandler, // Only local listeners
localAndGlobalHandler, // Both
firstLocalThenGlobalHandler, // Local first, global if no local (DEFAULT)
noHandlersThrowException, // Throw if no handlers at all
throwIfNoLocalHandler, // Throw if no local handler
}
Built-in filters:
// Default - local first, global as fallback
errorFilter: const GlobalIfNoLocalErrorFilter(),
// Local listeners only, no Sentry/global
errorFilter: const LocalErrorFilter(),
// Both local and global always
errorFilter: const LocalAndGlobalErrorFilter(),
// Global only (e.g., Sentry logging, no UI)
errorFilter: const GlobalErrorFilter(),
Custom filter function:
errorFilterFn: (Object error, StackTrace stackTrace) {
if (error is ApiException && error.code == 404) {
return ErrorReaction.localHandler; // UI handles 404
}
return ErrorReaction.firstLocalThenGlobalHandler; // Default for rest
},
Custom filter class:
class MyErrorFilter implements ErrorFilter {
ErrorReaction filter(Object error, StackTrace stackTrace) {
if (error is ApiException && error.code == 404) {
return ErrorReaction.localAndGlobalHandler;
}
return ErrorReaction.globalHandler;
}
}
Global exception handler (static property, not method):
Command.globalExceptionHandler = (CommandError error, StackTrace stackTrace) {
Sentry.captureException(error.error, stackTrace: stackTrace);
};
Global error stream - Command.globalErrors is a Stream<CommandError> of all globally-handled errors. Use registerStreamHandler in your root widget to show toasts for errors not handled locally:
// In root widget (e.g. MyApp)
registerStreamHandler(
target: Command.globalErrors,
handler: (context, snapshot, cancel) {
if (snapshot.hasData) showErrorToast(context, snapshot.data!.error);
},
);
Listening to errors:
.errors is ValueListenable<CommandError?> — the static type is nullable.
At runtime, handlers only fire with actual CommandError objects (null resets don't trigger handlers).
Use error! to promote — no null check needed (unless you call clearErrors()).
// With listen_it
command.errors.listen((error, subscription) {
showErrorDialog(error.error); // listen_it skips null emissions
});
// With watch_it registerHandler — use error! to promote (handler never called with null)
registerHandler(
select: (MyManager m) => m.deleteCommand.errors,
handler: (context, error, cancel) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Delete failed: ${error!.error}')),
);
},
);
Static Configuration
Command.globalExceptionHandler = ...; // Global error callback
Command.errorFilterDefault = LocalErrorFilter(); // Change default filter
Command.globalErrors; // Stream<CommandError> of all errors
Command.loggingHandler = (name, result) { }; // Log all command executions
Command.assertionsAlwaysThrow = true; // Default: true
Command.reportAllExceptions = false; // Default: false
Command.detailedStackTraces = true; // Default: true
Production Patterns
Async command with error filter:
late final getListingPreviewCommand =
Command.createAsync<GetListingPreviewRequest, SellerFeesDto?>(
(request) async {
final api = MarketplaceApi(di<ApiClient>());
return await api.getListingPreview(request);
},
debugName: 'getListingPreview',
initialValue: null,
errorFilter: const GlobalIfNoLocalErrorFilter(),
);
Undoable delete with recovery:
deletePostCommand = Command.createUndoableNoResult<PostProxy, PostProxy>(
(post, undoStack) async {
undoStack.push(post);
await PostApi(di<ApiClient>()).deletePost(post.id);
},
undo: (stack, error) {
final post = stack.pop();
di<EventBus>().sendUndoDeletePostEvent(post);
},
errorFilter: const GlobalIfNoLocalErrorFilter(),
);
Restriction chaining:
late final updateAvatarCommand = Command.createAsyncNoResult<File>(
(file) async { ... },
restriction: updateFromBackendCommand.isRunning,
);
Custom error filter hierarchy:
class LocalOnlyErrorFilter implements ErrorFilter {
ErrorReaction filter(Object error, StackTrace stackTrace) {
return ErrorReaction.localHandler;
}
}
class Api404ToSentry403LocalErrorFilter implements ErrorFilter {
ErrorReaction filter(Object error, StackTrace stackTrace) {
if (error is ApiException) {
if (error.code == 404) return ErrorReaction.localAndGlobalHandler;
if (error.code == 403) return ErrorReaction.localHandler;
}
return ErrorReaction.globalHandler;
}
}
Reacting to Command Completion
A Command is itself a ValueListenable. There are three levels of observation:
// ✅ Watch the command itself — fires ONLY on successful completion
registerHandler(
select: (MyManager m) => m.myCommand,
handler: (context, _, __) {
navigateAway(); // Only called on success
},
);
// ✅ Watch .errors — fires ONLY on errors
registerHandler(
select: (MyManager m) => m.myCommand.errors,
handler: (context, error, _) {
showError(error!.error.toString());
},
);
// Watch .results — fires on EVERY state change (isRunning, success, error)
// Use result.isSuccess / result.hasError / result.isRunning to distinguish
registerHandler(
select: (MyManager m) => m.myCommand.results,
handler: (context, result, _) {
if (result.isSuccess) { ... }
if (result.hasError) { ... }
if (result.isRunning) { ... }
},
);
Prefer watching the command itself for success and .errors for failures.
Only use .results when you need to react to all state transitions.
// ❌ DON'T use isRunning to detect success — fragile and ambiguous
registerHandler(
select: (MyManager m) => m.myCommand.isRunning,
handler: (context, isRunning, _) {
if (!isRunning && noError) { ... } // Easy to get wrong
},
);
// ✅ DO watch the command itself
registerHandler(
select: (MyManager m) => m.myCommand,
handler: (context, _, __) { ... }, // Only fires on success
);
Anti-Patterns
// ❌ Using deprecated execute()
command.execute();
// ✅ Use run()
command.run();
// ❌ Accessing isRunning on sync command
final cmd = Command.createSyncNoParamNoResult(() => print('hi'));
cmd.isRunning; // ASSERTION ERROR
// ✅ Use async command for loading states
final cmd = Command.createAsyncNoParamNoResult(() async => print('hi'));
cmd.isRunning; // Works
// ❌ Error filter returning bool
errorFilter: (error, hasLocal) => true // WRONG TYPE
// ✅ Return ErrorReaction enum
errorFilterFn: (error, stackTrace) => ErrorReaction.localHandler
// ❌ try/catch inside command body — commands handle errors automatically
late final saveCommand = Command.createAsyncNoParamNoResult(() async {
try {
await api.save();
} catch (e) {
cleanup();
rethrow;
}
});
// ✅ Use .errors.listen() for side effects on error
late final saveCommand = Command.createAsyncNoParamNoResult(
() async => await api.save(),
)..errors.listen((_, _) => cleanup());