The author is available for consulting & contract workBook a call →
Skip to content

Best Practices ​

Production-ready patterns, anti-patterns, and guidelines for using command_it effectively.

When to Use Commands ​

✅ Use Commands For ​

Async operations with UI feedback:

dart
late final loadDataCommand = Command.createAsyncNoParam<List<Data>>(
  () => api.fetchData(),
  initialValue: [],
);
// Automatic isRunning, error handling, UI integration

Operations that can fail:

dart
late final saveCommand = Command.createAsyncNoResult<Data>(
  (data) => api.save(data),
  errorFilter: PredicatesErrorFilter([
    (e, _) => errorFilter<ApiException>(e, ErrorReaction.localHandler),
  ]),
);

User-triggered actions:

dart
late final submitCommand = Command.createAsyncNoResult<FormData>(
  (data) => api.submit(data),
  restriction: formValid.map((valid) => !valid),
);

Operations needing state tracking:

  • Button loading states
  • Pull-to-refresh
  • Form submissions
  • Network requests
  • File I/O

✅ Use Sync Commands for Input with Operators ​

When you need to apply operators (debounce, map, where) to user input before triggering other operations, see Command Chaining for patterns using pipeToCommand and listen_it operators.

❌️️ Don't Use Commands For ​

Simple getters/setters (without operators or chaining):

dart
// ignore_for_file: unused_field, unused_local_variable
String _name = '';

// ❌ Overkill
late final getNameCommand = Command.createSyncNoParam<String>(
  () => _name,
  initialValue: '',
);

// ✅ Just use a ValueNotifier
final name = ValueNotifier<String>('');

Pure computations without side effects:

dart
// ❌ Unnecessary
late final calculateCommand = Command.createSync<int, int>(
  (n) => n * 2,
  initialValue: 0,
);

// ✅ Just use a function
int calculate(int n) => n * 2;

Immediate state changes:

dart
bool _enabled = false;

// ❌ Overcomplicated
late final toggleCommand = Command.createSyncNoParam<bool>(
  () => !_enabled,
  initialValue: false,
);

// ✅ Use ValueNotifier directly
final enabled = ValueNotifier<bool>(false);
void toggle() => enabled.value = !enabled.value;

Organization Patterns ​

Pattern 1: Commands in Managers ​

dart
class TodoManager {
  final ApiClient api;
  final Database db;

  TodoManager(this.api, this.db);

  // Group related commands
  late final loadTodosCommand = Command.createAsyncNoParam<List<Todo>>(
    () => api.fetchTodos(),
    initialValue: [],
  );

  late final addTodoCommand = Command.createAsyncNoResult<Todo>(
    (todo) async {
      await api.saveTodo(todo);
      loadTodosCommand.run(); // Reload after add
    },
  );

  late final deleteTodoCommand = Command.createAsyncNoResult<String>(
    (id) async {
      await api.deleteTodo(id);
      loadTodosCommand.run(); // Reload after delete
    },
    restriction: loadTodosCommand.isRunningSync, // Can't delete while loading
  );

  void dispose() {
    loadTodosCommand.dispose();
    addTodoCommand.dispose();
    deleteTodoCommand.dispose();
  }
}

Benefits:

  • Centralized business logic
  • Easy testing
  • Reusable across widgets
  • Clear ownership

Pattern 2: Feature-Based Organization ​

dart
// features/authentication/auth_manager.dart
class AuthManager {
  final ApiClient _api = ApiClient();

  // Expose login state as a simple ValueNotifier for restrictions
  final isLoggedIn = ValueNotifier<bool>(false);

  late final loginCommand = Command.createAsync<LoginCredentials, User>(
    (data) async {
      final user = await _api.login(data.username, data.password);
      isLoggedIn.value = true;
      return user;
    },
    initialValue: User.empty(),
  );

  late final logoutCommand = Command.createAsyncNoParamNoResult(
    () async {
      await _api.logout();
      isLoggedIn.value = false;
    },
  );
}

// features/profile/profile_manager.dart
class ProfileManager {
  final AuthManager auth;
  final ApiClient _api = ApiClient();

  ProfileManager(this.auth);

  late final loadProfileCommand = Command.createAsyncNoParam<Profile>(
    () => _api.loadProfile(),
    initialValue: Profile.empty(),
    // restriction: true = disabled, so negate isLoggedIn
    restriction: auth.isLoggedIn.map((logged) => !logged),
  );
}

Pattern 3: Commands in Data Proxies ​

Commands can also live in data objects that manage their own async operations. This is useful when each data item needs independent loading state:

dart
/// Data proxy that owns commands for lazy loading.
/// Each instance manages its own async operations.
class PodcastProxy {
  PodcastProxy({required this.feedUrl, required PodcastService podcastService})
      : _podcastService = podcastService {
    // Each proxy owns its fetch command
    fetchEpisodesCommand = Command.createAsyncNoParam<List<Episode>>(
      () async {
        if (_episodes != null) return _episodes!;
        _episodes = await _podcastService.fetchEpisodes(feedUrl);
        return _episodes!;
      },
      initialValue: [],
    );
  }

  final String feedUrl;
  final PodcastService _podcastService;
  List<Episode>? _episodes;

  late final Command<void, List<Episode>> fetchEpisodesCommand;

  /// Fetches episodes if not cached, then starts playback.
  late final playEpisodesCommand = Command.createAsyncNoResult<int>((
    startIndex,
  ) async {
    if (_episodes == null) {
      await fetchEpisodesCommand.runAsync();
    }
    if (_episodes != null && _episodes!.isNotEmpty) {
      // Start playback at index...
    }
  });

  List<Episode> get episodes => _episodes ?? [];
}

/// Manager creates and caches proxies
class PodcastManager {
  PodcastManager(this._podcastService);

  final PodcastService _podcastService;
  final _proxyCache = <String, PodcastProxy>{};

  PodcastProxy getOrCreateProxy(String feedUrl) {
    return _proxyCache.putIfAbsent(
      feedUrl,
      () => PodcastProxy(feedUrl: feedUrl, podcastService: _podcastService),
    );
  }
}

Benefits:

  • Each item has independent loading/error state
  • Caching logic lives with the data
  • UI can observe individual item state
  • Manager stays simple (just creates/caches proxies)

When to Use runAsync() ​

As explained in Command Basics, the core command pattern is fire-and-forget: call run() and let your UI observe state changes reactively. However, there are legitimate cases where using runAsync() is appropriate and more expressive than alternatives.

✅ Use runAsync() For Sequential Workflows ​

When commands are part of a larger async workflow mixed with other async operations:

dart
class PaymentManager {
  late final validatePaymentCommand = Command.createAsync<PaymentInfo, bool>(
    (info) => api.validatePayment(info),
    initialValue: false,
  );

  late final processPaymentCommand = Command.createAsync<PaymentInfo, Receipt>(
    (info) => api.processPayment(info),
    initialValue: Receipt.empty(),
  );

  // Complex async workflow
  Future<Receipt> completeCheckout(Cart cart, PaymentInfo payment) async {
    // Step 1: Validate inventory (not a command, just async call)
    final available = await api.checkInventory(cart.items);
    if (!available) throw InsufficientInventoryException();

    // Step 2: Validate payment (command)
    final isValid = await validatePaymentCommand.runAsync(payment);
    if (!isValid) throw InvalidPaymentException();

    // Step 3: Process payment (command)
    final receipt = await processPaymentCommand.runAsync(payment);

    // Step 4: Update inventory (not a command)
    await api.updateInventory(cart.items);

    return receipt;
  }
}

Why runAsync() here? The command is part of a larger async function that mixes command execution with regular async calls. Using runAsync() keeps the code linear and readable.

✅ Use runAsync() For APIs Requiring Futures ​

When interfacing with APIs that require a Future:

dart
class RefreshExample extends StatelessWidget {
  final Command<void, List<Data>> updateCommand;

  const RefreshExample({super.key, required this.updateCommand});

  @override
  Widget build(BuildContext context) {
    // RefreshIndicator requires Future<void>
    return RefreshIndicator(
      onRefresh: () => updateCommand.runAsync(),
      child: ListView(),
    );
  }
}

❌️ Don't Use runAsync() for Simple UI Updates ​

dart
class BadExample extends StatelessWidget {
  final Command<void, List<Data>> loadDataCommand;

  const BadExample({super.key, required this.loadDataCommand});

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // ❌ BAD: Blocking UI thread waiting for result
        ElevatedButton(
          onPressed: () async {
            final result = await loadDataCommand.runAsync();
            // Do nothing with result - just waiting
          },
          child: Text('Load Bad'),
        ),

        // ✅ GOOD: Fire and forget, let UI observe
        ElevatedButton(
          onPressed: loadDataCommand.run,
          child: Text('Load Good'),
        ),
      ],
    );
  }
}

Summary ​

Use runAsync() when:

  • ✅ Commands are part of a larger async workflow
  • ✅ An API requires a Future to be returned
  • ✅ The sequential flow is clearer with await than with .listen()

Don't use runAsync() when:

  • ❌️ Triggering commands from UI interactions (use run())
  • ❌️ You just want to observe results (use watchValue() or ValueListenableBuilder)
  • ❌️ The async/await adds no value over fire-and-forget

Performance Best Practices ​

Debounce Text Input ​

dart
class SearchManagerDebounce {
  late final searchTextCommand = Command.createSync<String, String>(
    (text) => text,
    initialValue: '',
  );

  late final searchCommand = Command.createAsync<String, List<Result>>(
    (query) => api.search(query),
    initialValue: [],
  );

  SearchManagerDebounce() {
    // Debounce text changes
    searchTextCommand.debounce(Duration(milliseconds: 300)).listen((text, _) {
      if (text.isNotEmpty) {
        searchCommand(text);
      }
    });
  }
}

Dispose Commands Properly ​

dart
class DataManager {
  late final command = Command.createAsyncNoParam<Data>(
    () => api.fetchData().then((list) => list.first),
    initialValue: Data.empty(),
  );

  // ✅ Always dispose in cleanup
  void dispose() {
    command.dispose();
  }
}

// With StatefulWidget
class MyWidget extends StatefulWidget {
  const MyWidget({super.key});

  @override
  State<MyWidget> createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
  final manager = DataManager();

  @override
  void dispose() {
    manager.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => Container();
}

// With get_it scopes
void registerWithGetIt() {
  getIt.registerLazySingleton<DataManager>(
    () => DataManager(),
    dispose: (manager) => manager.dispose(),
  );
}

Avoid Unnecessary Rebuilds ​

dart
class RebuildExample extends StatelessWidget {
  final Command<void, String> command;

  const RebuildExample({super.key, required this.command});

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // ❌ Rebuilds on every command property change
        ValueListenableBuilder(
          valueListenable: command.results,
          builder: (context, result, _) => Text(result.data?.toString() ?? ''),
        ),

        // ✅ Only rebuilds when value changes
        ValueListenableBuilder(
          valueListenable: command,
          builder: (context, data, _) => Text(data.toString()),
        ),
      ],
    );
  }
}

Restriction Best Practices ​

Use isRunningSync for Command Dependencies ​

dart
late final loadCommand = Command.createAsyncNoParam<List<Data>>(
  () => api.fetchData(),
  initialValue: [],
);

// ✅ Correct: Synchronous restriction
late final saveCommandGood = Command.createAsyncNoResult<Data>(
  (data) => api.save(data),
  restriction: loadCommand.isRunningSync, // Prevents race conditions
);

// ❌ Wrong: Async update can cause races
late final saveCommandBad = Command.createAsyncNoResult<Data>(
  (data) => api.save(data),
  restriction: loadCommand.isRunning, // Race condition possible!
);

Restriction Logic is Inverted ​

dart
final isLoggedIn = ValueNotifier<bool>(false);

// ❌ Common mistake: Restriction logic backwards
late final commandBad = Command.createAsyncNoParam<Data>(
  () => api.fetchData().then((list) => list.first),
  initialValue: Data.empty(),
  restriction: isLoggedIn, // WRONG: Disabled when logged in!
);

// ✅ Correct: Negate the condition
late final commandGood = Command.createAsyncNoParam<Data>(
  () => api.fetchData().then((list) => list.first),
  initialValue: Data.empty(),
  restriction:
      isLoggedIn.map((logged) => !logged), // Disabled when NOT logged in
);

Common Anti-Patterns ​

❌️️ Not Listening to Errors ​

dart
// ❌ BAD: Errors go nowhere
late final commandNoListener = Command.createAsyncNoParam<Data>(
  () => api.fetchData().then((list) => list.first),
  initialValue: Data.empty(),
  errorFilter: const LocalErrorFilter(),
);
// No error listener! Assertions in debug mode

// ✅ GOOD: Always listen to errors when using localHandler
void setupGoodErrorListener() {
  commandNoListener.errors.listen((error, _) {
    if (error != null) showError(error.error);
  });
}

void showError(Object error) {
  debugPrint('Error: $error');
}

❌️️ Try/Catch Inside Commands ​

Don't use try/catch inside command functions - it defeats command_it's error handling system:

dart
// ❌ BAD: try/catch inside command function
class DataManagerBad {
  late final loadCommand = Command.createAsyncNoParam<Data>(
    () async {
      try {
        final list = await api.fetchData();
        return list.first;
      } catch (e) {
        // Manual error handling defeats command_it's error system
        debugPrint('Error: $e');
        rethrow;
      }
    },
    initialValue: Data.empty(),
  );
}

// ✅ GOOD: Let command handle errors, use ..errors.listen()
class DataManagerGood {
  late final loadCommand = Command.createAsyncNoParam<Data>(
    () async {
      final list = await api.fetchData();
      return list.first;
    },
    initialValue: Data.empty(),
  )..errors.listen((error, _) {
      if (error != null) {
        debugPrint('Error: ${error.error}');
      }
    });
}

See Also ​

Released under the MIT License. Built by Thomas Burkhart.