Command Results
Deep dive into CommandResult - the comprehensive state object that combines execution state, result data, errors, and parameters in a single observable property.
Overview
The .results property is a ValueListenable<CommandResult<TParam, TResult>> that provides all command execution information in a single value class. This property updates on every state change of the command (running, success, error):
class CommandResult<TParam, TResult> {
final TParam? paramData; // Parameter passed to command
final TResult? data; // Result value
final bool isUndoValue; // True if this is from an undo operation
final Object? error; // Error if thrown
final bool isRunning; // Execution state
final ErrorReaction? errorReaction; // How error was handled (if error occurred)
final StackTrace? stackTrace; // Error stack trace (if error occurred)
// Convenience getters
bool get hasData => data != null;
bool get hasError => error != null && !isUndoValue; // Excludes undo errors
bool get isSuccess => !isRunning && !hasError;
}Access via .results property:
ValueListenableBuilder<CommandResult<String, List<Todo>>>(
valueListenable: command.results,
builder: (context, result, _) {
// Use result.data, result.error, result.isRunning, etc.
},
)When to Use CommandResult
Use .results when you need:
- ✅ All state in one place (running, data, error)
- ✅ Parameter data for error messages
- ✅ Single builder instead of multiple nested builders
- ✅ Comprehensive state handling
Use individual properties when:
- Just need the data: Use command itself (
ValueListenable<TResult>) - Just need loading state: Use `.isRunning`
- Just need errors: Use `.errors`
- Want to avoid rebuilds on every state change (individual properties only update for their specific state)
Result State Transitions
Normal Flow (Success)
Initial: { data: null, error: null, isRunning: false }
↓ command.run('query')
Running: { data: null, error: null, isRunning: true }
↓ async operation completes
Success: { data: [results], error: null, isRunning: false }Note: Initial data is null unless you set an initialValue parameter when creating the command.
Error Flow
Initial: { data: null, error: null, isRunning: false }
↓ command.run('query')
Running: { data: null, error: null, isRunning: true }
↓ exception thrown
Error: { data: null, error: Exception(), isRunning: false }includeLastResultInCommandResults
By default, CommandResult.data becomes null during command execution and when errors occur. Set includeLastResultInCommandResults: true to keep the last successful value visible in both states:
Command.createAsync<String, List<Todo>>(
(query) => api.search(query),
initialValue: [],
includeLastResultInCommandResults: true, // Keep old data visible
);When this flag affects behavior:
- During execution (
isRunning: true) - Old data remains inresult.datainstead of becomingnull - During error states (
hasError: true) - Old data remains inresult.datainstead of becomingnull
Modified flow (with initialValue: []):
Initial: { data: [], error: null, isRunning: false }
↓ command.run('query')
Running: { data: [], error: null, isRunning: true } ← Old data kept
↓ success
Success: { data: [new results], error: null, isRunning: false }
↓ command.run('query2')
Running: { data: [old results], error: null, isRunning: true } ← Still visible
↓ error
Error: { data: [old results], error: Exception(), isRunning: false } ← Still visibleCommon use cases:
- Pull-to-refresh - Show stale data while loading fresh data
- Stale-while-revalidate - Keep showing old content during updates
- Error recovery - Display last known good data even when errors occur
- Optimistic UI - Maintain UI stability during background refreshes
When to use:
- ✅ List/feed refresh scenarios where empty states look jarring
- ✅ Search results that update incrementally
- ✅ Data that's better stale than absent
- ❌️ Login/authentication where stale data is misleading
- ❌️ Critical data where showing old values during errors is unsafe
Complete Example
With watch_it (Recommended)
class WeatherResultWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
// Watch the results property for all state
final results = watchValue(
(WeatherManager m) => m.loadWeatherCommand.results,
);
// Check execution state
if (results.isRunning) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Loading weather for ${results.paramData ?? ""}...'),
],
),
);
}
// Check for errors
if (results.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error, color: Colors.red, size: 48),
SizedBox(height: 16),
Text('Error: ${results.error}'),
if (results.paramData != null)
Text('For city: ${results.paramData}'),
SizedBox(height: 16),
ElevatedButton(
onPressed: () => weatherManager.loadWeatherCommand('London'),
child: Text('Retry'),
),
],
),
);
}
// Check for data
if (results.hasData && results.data!.isNotEmpty) {
return ListView.builder(
itemCount: results.data!.length,
itemBuilder: (context, index) {
final entry = results.data![index];
return ListTile(
title: Text(entry.city),
subtitle: Text(entry.condition),
trailing: Text('${entry.temperature}°F'),
);
},
);
}
// Initial state
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () => weatherManager.loadWeatherCommand('London'),
child: Text('Load Weather'),
),
SizedBox(height: 8),
ElevatedButton(
onPressed: () {
weatherManager.shouldFail = true;
weatherManager.loadWeatherCommand('Paris');
},
child: Text('Load Weather (will fail)'),
),
],
),
);
}
}How it works:
watchValueobserves.resultsproperty- Widget rebuilds automatically when state changes
- Check
result.isRunningfirst → show loading - Check
result.hasErrornext → show error (with param data) - Check
result.hasData→ show data - Fallback → initial state
Without watch_it
class WeatherResultWidget extends StatelessWidget {
WeatherResultWidget({super.key});
final manager = WeatherManager();
@override
Widget build(BuildContext context) {
// Use results property for all data at once
return ValueListenableBuilder<CommandResult<String?, List<WeatherEntry>>>(
valueListenable: manager.loadWeatherCommand.results,
builder: (context, result, _) {
// Check execution state
if (result.isRunning) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Loading weather for ${result.paramData ?? ""}...'),
],
),
);
}
// Check for errors
if (result.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error, color: Colors.red, size: 48),
SizedBox(height: 16),
Text('Error: ${result.error}'),
if (result.paramData != null)
Text('For city: ${result.paramData}'),
SizedBox(height: 16),
ElevatedButton(
onPressed: () => manager.loadWeatherCommand('London'),
child: Text('Retry'),
),
],
),
);
}
// Check for data
if (result.hasData && result.data!.isNotEmpty) {
return ListView.builder(
itemCount: result.data!.length,
itemBuilder: (context, index) {
final entry = result.data![index];
return ListTile(
title: Text(entry.city),
subtitle: Text(entry.condition),
trailing: Text('${entry.temperature}°F'),
);
},
);
}
// Initial state
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () => manager.loadWeatherCommand('London'),
child: Text('Load Weather'),
),
SizedBox(height: 8),
ElevatedButton(
onPressed: () {
manager.shouldFail = true;
manager.loadWeatherCommand('Paris');
},
child: Text('Load Weather (will fail)'),
),
],
),
);
},
);
}
}Same logic using ValueListenableBuilder for users who prefer not to use watch_it.
Using .toWidget() with CommandResult
The .toWidget() extension method from command_it provides a declarative way to build UI from CommandResult by providing separate builders for each state:
class WeatherToWidgetExample extends WatchingWidget {
@override
Widget build(BuildContext context) {
final results = watchValue(
(WeatherManager m) => m.loadWeatherCommand.results,
);
return results.toWidget(
onData: (weather, param) {
return ListView.builder(
itemCount: weather.length,
itemBuilder: (context, index) {
final entry = weather[index];
return ListTile(
title: Text(entry.city),
subtitle: Text(entry.condition),
trailing: Text('${entry.temperature}°F'),
);
},
);
},
whileRunning: (lastWeather, param) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Loading weather for ${param ?? ""}...'),
],
),
);
},
onError: (error, lastWeather, param) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error, color: Colors.red, size: 48),
SizedBox(height: 16),
Text('Error: $error'),
if (param != null) Text('For city: $param'),
SizedBox(height: 16),
ElevatedButton(
onPressed: () => weatherManager.loadWeatherCommand('London'),
child: Text('Retry'),
),
],
),
);
},
);
}
}Benefits of .toWidget():
- Declarative approach - separate builder for each state
- No need for manual
ifchecks on state - Clear separation of concerns
- Compiler ensures all states are handled
Parameters:
You must provide at least one of these two:
onData-Widget Function(TResult result, TParam? param)?- Called when command has non-null data (only if
onSuccessnot provided) - Receives both the result data and parameter
- Use for commands that return data you need to display
- Called when command has non-null data (only if
onSuccess-Widget Function(TParam? param)?- Called on successful completion (no error, not running)
- Does NOT receive result data, only the parameter
- Takes priority over
onDataif both provided - Use for void-returning commands or when you don't need the result value
Optional builders:
whileRunning-Widget Function(TResult? lastResult, TParam? param)?- Called while command executes
- Receives last result (if
includeLastResultInCommandResults: true) and parameter
onError-Widget Function(Object error, TResult? lastResult, TParam? param)?- Called when error occurs
- Receives error, last result, and parameter
onNullData-Widget Function(TParam? param)?- Called when data is null (only if neither
onSuccessnoronDatahandle it) - Receives only the parameter
- Called when data is null (only if neither
onData vs onSuccess
Execution priority: If command completes successfully, .toWidget() checks in this order:
- If
onSuccessprovided → call it (doesn't check if data is null) - Else if data != null → call
onData - Else → call
onNullData
Choose onSuccess when:
- Command returns void (e.g.,
Command.createAsyncNoResult) - You only need to show confirmation/success message
- Result data is irrelevant to the UI
Choose onData when:
- Command returns data you need to display/use
- You want to handle non-null data differently from null data
When to use .toWidget():
- Prefer declarative builder pattern over imperative state checks
- Want clear separation between different states
- Each state maps to exactly one UI representation
When to use manual state checks instead:
- Need to display multiple states simultaneously (e.g., show data with loading indicator on top)
- Need complex conditional logic combining multiple states
- Prefer imperative style with
ifstatements
Result Properties
data - The Result Value
if (result.hasData) {
final items = result.data!; // Safe to unwrap
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, i) => ItemTile(items[i]),
);
}Behavior:
nullwhile command is running (unlessincludeLastResultInCommandResults)nullon error (unlessincludeLastResultInCommandResults)- Contains result value on success
- Always
nullforvoidresult commands
Nullability:
- Type is
TResult?(nullable) - Use
hasDatato check before accessing - Safe to unwrap after
hasDatacheck
error - The Exception
if (result.hasError) {
return ErrorWidget(
message: result.error.toString(),
onRetry: command.run,
);
}Behavior:
nullwhen no error- Contains thrown exception on failure
- Cleared to
nullwhen command runs again - Type is
Object?(any throwable)
CommandResult.error vs Command.errors Property
Important distinction:
CommandResult.errorcontains the raw/pure error object (typeObject?)- The command's
.errorsproperty containsCommandError<TParam>?which wraps the error with additional context (parameter data, command name, stack trace, error reaction)
When using CommandResult, you get direct access to the thrown error. When using the .errors property, you get the error wrapped with metadata.
Error types:
if (result.hasError) {
if (result.error is ApiException) {
// Handle API errors
} else if (result.error is ValidationException) {
// Handle validation errors
} else {
// Generic error
}
}UI Error Handling vs Error Filters
The above pattern is recommended for displaying different UI based on error type. For more sophisticated error handling strategies (routing errors to different handlers, logging, rethrowing, silencing specific errors, etc.), use Error Filters which offer much richer possibilities for controlling error reactions.
isRunning - Execution State
if (result.isRunning) {
return Center(
child: Column(
children: [
CircularProgressIndicator(),
Text('Loading...'),
],
),
);
}Behavior:
truewhile async function executesfalseinitially and after completion- Updates asynchronously (via microtask) - see Command Properties
paramData - The Input Parameter
if (result.hasError) {
return Column(
children: [
Text('Error: ${result.error}'),
if (result.paramData != null)
Text('Failed for query: ${result.paramData}'),
ElevatedButton(
onPressed: () => command(result.paramData), // Retry with same param
child: Text('Retry'),
),
],
);
}Behavior:
- Contains the parameter passed to command
nullfor no-param commands- Type is
TParam?(nullable) - Useful for error messages and retry logic
Use cases:
- Show what query failed in error message
- Retry button with same parameters
- Logging which operation failed
Convenience Getters
hasData
bool get hasData => data != null;
// Usage
if (result.hasData) {
return DataView(result.data!);
}Preferred over:
if (result.data != null) { ... }hasError
bool get hasError => error != null;
// Usage
if (result.hasError) {
return ErrorView(result.error.toString());
}Preferred over:
if (result.error != null) { ... }isSuccess
bool get isSuccess => !hasError && !isRunning;
// Usage
if (result.isSuccess && result.hasData) {
return SuccessView(result.data!);
}Useful for:
- Distinguishing successful completion from initial state
- Showing success animations/messages
- Conditional rendering after completion
Patterns with CommandResult
Pattern 1: Progressive States
With watch_it:
class MyWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
final result = watchValue((Manager m) => m.command.results);
// 1. Loading
if (result.isRunning) {
return LoadingState(query: result.paramData);
}
// 2. Error
if (result.hasError) {
return ErrorState(
error: result.error!,
query: result.paramData,
onRetry: () => di<Manager>().command(result.paramData),
);
}
// 3. Success
if (result.hasData) {
return DataState(data: result.data!);
}
// 4. Initial (no data, no error, not running)
return InitialState();
}
}Without watch_it:
ValueListenableBuilder<CommandResult<String, Data>>(
valueListenable: command.results,
builder: (context, result, _) {
// 1. Loading
if (result.isRunning) {
return LoadingState(query: result.paramData);
}
// 2. Error
if (result.hasError) {
return ErrorState(
error: result.error!,
query: result.paramData,
onRetry: () => command(result.paramData),
);
}
// 3. Success
if (result.hasData) {
return DataState(data: result.data!);
}
// 4. Initial (no data, no error, not running)
return InitialState();
},
)Pattern 2: Optimistic UI with Stale Data
Setup:
Command.createAsync<String, List<Item>>(
(query) => api.search(query),
initialValue: [],
includeLastResultInCommandResults: true, // Keep old data
);With watch_it:
class SearchWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
final result = watchValue((SearchManager m) => m.searchCommand.results);
return Stack(
children: [
// Always show data (old or new)
if (result.hasData)
ItemList(items: result.data!),
// Overlay loading indicator
if (result.isRunning)
Positioned(
top: 0,
left: 0,
right: 0,
child: LinearProgressIndicator(),
),
// Show error banner
if (result.hasError)
ErrorBanner(error: result.error),
],
);
}
}Without watch_it:
ValueListenableBuilder<CommandResult<String, List<Item>>>(
valueListenable: searchCommand.results,
builder: (context, result, _) {
return Stack(
children: [
// Always show data (old or new)
if (result.hasData)
ItemList(items: result.data!),
// Overlay loading indicator
if (result.isRunning)
Positioned(
top: 0,
left: 0,
right: 0,
child: LinearProgressIndicator(),
),
// Show error banner
if (result.hasError)
ErrorBanner(error: result.error),
],
);
},
)Pattern 3: Retry with Original Parameters
if (result.hasError) {
return ErrorView(
error: result.error!,
operation: 'Searching for "${result.paramData}"',
onRetry: () {
// Retry with exact same parameter
command(result.paramData);
},
);
}Pattern 4: Logging with Context
Use the .errors property for logging - it provides richer context than CommandResult.error:
command.errors.listen((commandError, _) {
if (commandError != null) {
logger.error(
'Command failed: ${commandError.command}',
error: commandError.error,
stackTrace: commandError.stackTrace,
param: commandError.paramData,
errorReaction: commandError.errorReaction,
);
}
});Why .errors is better for logging:
- Includes
stackTraceautomatically captured - Provides
commandname for identifying which command failed - Contains
errorReactionshowing how the error was handled - All context bundled in
CommandError<TParam>wrapper
CommandResult vs Individual Properties
Using individual properties (multiple watchers)
// With watch_it - only rebuilds for properties you watch
class TodoWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
final isRunning = watchValue((TodoManager m) => m.loadTodos.isRunning);
final todos = watchValue((TodoManager m) => m.loadTodos);
if (isRunning) return CircularProgressIndicator();
return TodoList(todos: todos);
}
}Benefits:
- Each property only updates when its value changes
- No
ifchecks needed when watching 1-2 properties - Fewer rebuilds - only when watched properties change
Using CommandResult (single watcher)
// Single property with if checks
class TodoWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
final result = watchValue((TodoManager m) => m.loadTodos.results);
if (result.isRunning) return CircularProgressIndicator();
if (result.hasError) return ErrorWidget(result.error);
return TodoList(todos: result.data ?? []);
}
}Trade-offs:
- More rebuilds: Updates on every state change (running, success, error)
- Requires
ifchecks: Must check state properties - Single watcher: All state in one place
- Better for: When you need 3+ properties or all state information
Recommendation:
- Need only 1-2 properties (e.g., just data + isRunning): Use individual properties
- Need 3+ properties or complete state: Use CommandResult
Common Mistakes
❌️️ Accessing data without null check
// WRONG: data might be null
return ListView.builder(
itemCount: result.data.length, // Crash if null!
...
);// CORRECT: Check hasData first
if (result.hasData) {
return ListView.builder(
itemCount: result.data!.length,
...
);
}❌️️ Wrong state check order
// WRONG: Checks data before checking isRunning
if (result.hasData) return DataView(result.data!);
if (result.isRunning) return LoadingView();// CORRECT: Check isRunning first
if (result.isRunning) return LoadingView();
if (result.hasData) return DataView(result.data!);❌️️ Ignoring initial state
// WRONG: What if no data, no error, not running?
if (result.isRunning) return LoadingView();
if (result.hasError) return ErrorView(result.error!);
return DataView(result.data!); // Crash on initial state!// CORRECT: Handle all states
if (result.isRunning) return LoadingView();
if (result.hasError) return ErrorView(result.error!);
if (result.hasData) return DataView(result.data!);
return InitialView(); // Initial stateSee Also
- Command Properties — All command observable properties
- Command Basics — Creating and running commands
- Error Handling — Error property usage
- CommandBuilder Widget — Widget that uses CommandResult