Skip to content

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:

dart
// ❌ Manual approach - more boilerplate
sourceCommand.listen((value, _) {
  if (value.isNotEmpty) {
    targetCommand(value);
  }
});

Use pipeToCommand for cleaner, declarative chaining:

dart
// ✅ Declarative approach
sourceCommand
    .where((value) => value.isNotEmpty)
    .pipeToCommand(targetCommand);

Benefits:

  • Declarative and readable
  • Combines with listen_it operators (debounce, where, map)
  • Returns ListenableSubscription for 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:

dart
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:

dart
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:

dart
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):

dart
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:

dart
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

dart
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:

dart
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:

dart
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:

  1. Transform provided → Uses transform function
  2. Types match → Passes value directly to target.run(value)
  3. Types don't match → Calls target.run() without parameters

This means you can pipe to no-parameter commands without a transform:

dart
// 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

dart
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

dart
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):

dart
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:

dart
// ❌ DANGER: Infinite loop!
commandA.pipeToCommand(commandB);
commandB.pipeToCommand(commandA);  // A triggers B triggers A triggers B...

API Reference

dart
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 changes
  • transform — Optional function to convert source value to target parameter type

Returns: ListenableSubscription — Cancel this to stop the pipe

See Also

Released under the MIT License.