Command Chaining
Connect commands together declaratively using pipeToCommand. When a source ValueListenable changes, it automatically triggers the target command.
Why Use pipeToCommand?
Instead of manually setting up listeners:
// ❌ Manual approach - more boilerplate
sourceCommand.listen((value, _) {
if (value.isNotEmpty) {
targetCommand(value);
}
});Use pipeToCommand for cleaner, declarative chaining:
// ✅ Declarative approach
sourceCommand
.where((value) => value.isNotEmpty)
.pipeToCommand(targetCommand);Benefits:
- Declarative and readable
- Combines with
listen_itoperators (debounce, where, map) - Returns
ListenableSubscriptionfor easy cleanup - Works on any
ValueListenable, not just commands
Inline Chaining with Cascade
The cleanest pattern: use Dart's cascade operator .. to chain directly on command definition:
class DataManager {
late final refreshCommand = Command.createAsyncNoParam<List<Data>>(
() => api.fetchData(),
initialValue: [],
);
// Chain directly on definition - no constructor needed!
late final saveCommand = Command.createAsyncNoResult<Data>(
(data) => api.save(data),
)..pipeToCommand(refreshCommand);
}This eliminates the need for a constructor just to set up pipes. The subscription is managed automatically when the command is disposed.
Basic Usage
pipeToCommand works on any ValueListenable:
From a Command
When one command completes, trigger another:
class DataManager {
late final refreshCommand = Command.createAsyncNoParam<List<Data>>(
() => api.fetchData(),
initialValue: [],
);
// When saveCommand completes, automatically refresh
late final saveCommand = Command.createAsyncNoResult<Data>(
(data) => api.save(data),
)..pipeToCommand(refreshCommand);
void dispose() {
saveCommand.dispose();
refreshCommand.dispose();
}
}From isRunning
React to command execution state:
class SpinnerManager {
// Command that controls a global spinner
late final showSpinnerCommand = Command.createSync<bool, bool>(
(show) => show,
initialValue: false,
);
// When long command starts/stops, update spinner
late final longRunningCommand = Command.createAsyncNoParam<Data>(
() async {
await Future.delayed(Duration(seconds: 5));
return Data();
},
initialValue: Data.empty(),
)..isRunning.pipeToCommand(showSpinnerCommand);
}From results
Pipe the full CommandResult (includes success/error state):
class LoggingManager {
late final logCommand = Command.createSync<CommandResult<Data, Data>, void>(
(result) {
if (result.hasError) {
debugPrint('Save failed: ${result.error}');
} else if (result.hasData) {
debugPrint('Save succeeded: ${result.data}');
}
},
initialValue: null,
);
// Pipe all results (success/error) to logging
late final saveCommand = Command.createAsync<Data, Data>(
(data) async {
await api.save(data);
return data;
},
initialValue: Data.empty(),
)..results.pipeToCommand(logCommand);
}From ValueNotifier
Works with plain ValueNotifier too:
class FormManager {
late final loadUserCommand = Command.createAsync<String, User>(
(userId) => api.login(userId, ''),
initialValue: User.empty(),
);
// When user ID changes, load user details
late final selectedUserId = ValueNotifier<String>('')
..pipeToCommand(loadUserCommand);
void dispose() {
selectedUserId.dispose();
loadUserCommand.dispose();
}
}Transform Function
When source and target types don't match, use the transform parameter:
Basic Transform
class UserManager {
// Command expects String, but we have int
late final fetchUserCommand = Command.createAsync<String, User>(
(userId) => api.login(userId, ''),
initialValue: User.empty(),
);
// Transform int to String
late final selectedUserId = ValueNotifier<int>(0)
..pipeToCommand(fetchUserCommand, transform: (id) => id.toString());
}Complex Transform
Create complex parameter objects:
class FetchParams {
final String userId;
final bool includeDetails;
FetchParams(this.userId, {this.includeDetails = true});
}
class DetailedUserManager {
late final fetchUserCommand = Command.createAsync<FetchParams, User>(
(params) => api.login(params.userId, ''),
initialValue: User.empty(),
);
// Transform simple ID to complex params object
late final selectedUserId = ValueNotifier<String>('')
..pipeToCommand(
fetchUserCommand,
transform: (id) => FetchParams(id, includeDetails: true),
);
}Transform Results
Transform command results before piping:
class ResultTransformManager {
// Notification command only needs a message
late final notifyCommand = Command.createSync<String, void>(
(message) => debugPrint('Notification: $message'),
initialValue: null,
);
// Transform Data result to notification message
late final saveCommand = Command.createAsync<Data, Data>(
(data) async {
await api.save(data);
return data;
},
initialValue: Data.empty(),
)..pipeToCommand(notifyCommand,
transform: (data) => 'Saved item: ${data.id}');
}Type Handling
pipeToCommand handles types automatically:
- Transform provided → Uses transform function
- Types match → Passes value directly to
target.run(value) - Types don't match → Calls
target.run()without parameters
This means you can pipe to no-parameter commands without a transform:
// saveCommand returns Data, refreshCommand takes no params
saveCommand.pipeToCommand(refreshCommand); // Works! Calls refreshCommand.run()Combining with listen_it Operators
The real power comes from combining pipeToCommand with listen_it operators like debounce, where, and map:
Search with Debounce
class SearchManager {
late final textChangedCommand = Command.createSync<String, String>(
(s) => s,
initialValue: '',
);
late final searchCommand = Command.createAsync<String, List<Result>>(
(query) => api.search(query),
initialValue: [],
);
late final ListenableSubscription _subscription;
SearchManager() {
// Debounce + filter + pipe to search command
_subscription = textChangedCommand
.debounce(Duration(milliseconds: 500))
.where((text) => text.length >= 3)
.pipeToCommand(searchCommand);
}
void dispose() {
_subscription.cancel();
textChangedCommand.dispose();
searchCommand.dispose();
}
}Filter Before Piping
class FilteredPipeManager {
late final inputCommand = Command.createSync<int, int>(
(n) => n,
initialValue: 0,
);
late final processCommand = Command.createAsync<int, String>(
(n) async => 'Processed: $n',
initialValue: '',
);
late final ListenableSubscription _subscription;
FilteredPipeManager() {
// Only pipe positive numbers, debounced
_subscription = inputCommand
.where((n) => n > 0)
.debounce(Duration(milliseconds: 200))
.pipeToCommand(processCommand);
}
void dispose() {
_subscription.cancel();
inputCommand.dispose();
processCommand.dispose();
}
}Subscription Management
pipeToCommand returns a ListenableSubscription. However, you usually don't need to manage it manually.
Automatic Cleanup
When commands and pipes are in the same object (manager/service class), the subscription is automatically garbage collected when the containing object becomes unreachable. No manual cleanup needed!
See listen_it Memory Management for details.
For cases where you do need manual control (e.g., dynamically created pipes with operators):
class CleanupManager {
late final sourceCommand = Command.createSync<String, String>(
(s) => s,
initialValue: '',
);
late final targetCommand = Command.createAsyncNoResult<String>(
(s) async => api.saveContent(s),
);
// Store subscription for cleanup
late final ListenableSubscription _subscription;
CleanupManager() {
_subscription = sourceCommand.pipeToCommand(targetCommand);
}
void dispose() {
// Cancel subscription first
_subscription.cancel();
// Then dispose commands
sourceCommand.dispose();
targetCommand.dispose();
}
}Warning: Circular Pipes
Avoid Circular Pipes
Never create circular pipe chains - they cause infinite loops:
// ❌ DANGER: Infinite loop!
commandA.pipeToCommand(commandB);
commandB.pipeToCommand(commandA); // A triggers B triggers A triggers B...API Reference
extension ValueListenablePipe<T> on ValueListenable<T> {
ListenableSubscription pipeToCommand<TTargetParam, TTargetResult>(
Command<TTargetParam, TTargetResult> target, {
TTargetParam Function(T value)? transform,
})
}Parameters:
target— The command to trigger when the source changestransform— Optional function to convert source value to target parameter type
Returns: ListenableSubscription — Cancel this to stop the pipe
See Also
- Restrictions — Disable commands based on state
- Command Properties — Observable properties like
isRunning - listen_it Operators — Operators like
debounce,where,map