Command Basics
Learn how to create and run commands, the foundation of command_it.
Examples Use watch_it
All examples use watch_it for observing commands. See Without watch_it if you prefer ValueListenableBuilder.
What is a Command?
A Command wraps a function (sync or async) and makes it observable. Instead of calling a function directly and manually tracking its state, you create a command that:
- Executes your function when called
- Automatically tracks execution state (
isRunning) - Publishes results via
ValueListenable - Handles errors gracefully
- Prevents parallel execution
Think of it as: A function + automatic state management + reactive notifications.
The Command Pattern
The core philosophy: Start commands with run() (fire and forget), then your app/UI observes and reacts to their state changes. This reactive pattern keeps your UI responsive with no blocking—you trigger the action and let your UI automatically respond to loading states, results, and errors.
Creating Your First Command
Commands are created using static factory functions, not constructors. The most common type is createAsyncNoParam for async functions without parameters:
// 1. Create a service with a command
class CounterService {
int _counter = 0;
late final incrementCommand = Command.createAsyncNoParam<String>(
() async {
await Future.delayed(Duration(milliseconds: 500));
_counter++;
return _counter.toString();
},
initialValue: '0',
);
}
// Register with get_it (call this in main())
void setup() {
GetIt.instance.registerSingleton(CounterService());
}
// 2. Use watch_it to observe the command
class CounterWidget extends WatchingWidget {
const CounterWidget({super.key});
@override
Widget build(BuildContext context) {
// Watch the command value
final count = watchValue((CounterService s) => s.incrementCommand);
// Watch the loading state
final isRunning =
watchValue((CounterService s) => s.incrementCommand.isRunning);
return Column(
children: [
// Shows loading indicator automatically while command runs
if (isRunning) CircularProgressIndicator() else Text('Count: $count'),
SizedBox(height: 16),
ElevatedButton(
onPressed: GetIt.instance<CounterService>().incrementCommand.run,
child: Text('Increment'),
),
],
);
}
}What happens:
- Command wraps your async function
- When
run()is called, the function executes - While running,
isRunningistrue - Result is published to
valueproperty - UI rebuilds automatically via
watchValue
Running Commands
There are two ways to execute a command:
1. Using run() (Fire and Forget)
// Call the command's run method
loadDataCommand.run();
// Or with a parameter
searchCommand.run('flutter');Use run() when you want to trigger execution without waiting for the result. Perfect for button handlers.
2. Calling as Callable Class
Commands are callable classes, so you can invoke them directly:
// Callable - same as run()
loadDataCommand();
// With parameter
searchCommand('flutter');This is just shorthand for run() - it doesn't return a value.
Why Use .run for Tearoffs?
In the past, it was possible to pass callable classes directly as tearoffs. However, due to changes in Dart, this is no longer possible. For optional VoidCallbacks (like onPressed), passing a callable class directly is now a compiler error. Even when it compiles, it triggers the implicit_call_tearoffs linter warning because Dart implicitly tears off the .call() method, which is considered unclear.
Always use .run for tearoffs:
// ✅ Good - explicit tearoff
ElevatedButton(onPressed: command.run, ...)
// ❌ Avoid - implicit call tearoff (compiler error for optional VoidCallback)
ElevatedButton(onPressed: command, ...)This is why command_it renamed from execute() to run() in v9.0.0 - making the explicit method the primary API.
3. Using runAsync() (Await Result)
Use runAsync() when you need to await the result:
final result = await loadDataCommand.runAsync();Use Sparingly
runAsync() breaks the fire-and-forget pattern described above. Only use it when an API requires a Future to be returned (like RefreshIndicator.onRefresh). For normal application code, always use run() and observe state changes reactively.
Perfect for RefreshIndicator:
RefreshIndicator(
onRefresh: () => updateCommand.runAsync(),
child: ListView(...),
)Commands with Parameter and Return Type
Most commands need both parameters and return values. Use createAsync<TParam, TResult> for async functions with a parameter and result:
late final searchCommand = Command.createAsync<String, List<Todo>>(
(query) async {
await Future.delayed(Duration(milliseconds: 500));
return fakeTodos.where((t) => t.title.contains(query)).toList();
},
initialValue: [],
);
// Call with parameter
searchCommand.run('flutter');Type parameters:
- First type (
String) = parameter type - Second type (
List<Todo>) = result type
Synchronous Commands
For synchronous functions, use createSync:
late final formatCommand = Command.createSync<String, String>(
(text) => text.toUpperCase(),
initialValue: '',
);
// Use exactly like async commands
formatCommand.run('hello');Important: Sync commands don't support isRunning - accessing it will throw an exception because the UI can't update while synchronous functions execute.
Initial Values
Commands that return a value require an initialValue:
Command.createAsyncNoParam<List<Todo>>(
() => api.fetchTodos(),
initialValue: [], // Required: what value before first execution?
);Why? Commands are ValueListenable<TResult>. They need a value from the start, before the first execution completes. This is especially important if the command's value should be displayed in a widget—widgets need a value on the first build even if the command hasn't run yet.
Commands returning void don't need initial values:
Command.createAsyncNoResult<String>(
(message) => api.sendMessage(message),
// No initialValue needed
);Automatic Parallel Execution Prevention
Commands automatically prevent parallel execution:
final saveCommand = Command.createAsyncNoParam<void>(
() async {
await Future.delayed(Duration(seconds: 2));
await api.save();
},
);
// Click button rapidly
saveCommand.run(); // Starts execution
saveCommand.run(); // Ignored - already running
saveCommand.run(); // Ignored - already running
// ... 2 seconds pass ...
saveCommand.run(); // Now this one executesThis prevents:
- Double-submissions
- Race conditions
- Wasted API calls
Using Commands in Managers
Best practice: Create commands in manager/controller classes, not in widgets:
class TodoManager {
final api = ApiClient();
late final loadTodosCommand = Command.createAsyncNoParam<List<Todo>>(
() => api.fetchTodos(),
initialValue: [],
);
late final saveTodoCommand = Command.createAsyncNoResult<Todo>(
(todo) => api.saveTodo(todo),
);
}
// In widget
class TodoListWidget extends StatelessWidget {
final manager = TodoManager();
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<List<Todo>>(
valueListenable: manager.loadTodosCommand,
builder: (context, todos, _) => ListView(...),
);
}
}Why?
- Separates business logic from UI
- Easier to test
- Reusable across widgets
- Clear responsibility boundaries
Disposing Commands
Commands must be disposed to prevent memory leaks:
class TodoManager {
late final loadCommand = Command.createAsyncNoParam<List<Todo>>(...);
void dispose() {
loadCommand.dispose();
}
}When using StatefulWidget:
class MyWidget extends StatefulWidget {
@override
State<MyWidget> createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget> {
late final TodoManager manager;
@override
void initState() {
super.initState();
manager = TodoManager();
}
@override
void dispose() {
manager.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => ...;
}With get_it: Register as singleton and dispose on app shutdown or use scopes for automatic cleanup. With watch_it: Use createOnce() for automatic lifecycle management.
See Also
- Command Properties — value, isRunning, canRun, errors, results
- Command Types — All factory functions
- Error Handling — Handling errors gracefully
- Best Practices — Production patterns