Skip to content

Command Restrictions

Control when commands can execute using reactive conditions. Restrictions enable declarative connection of command behavior - connect commands to application state or to each other, and they automatically enable/disable based on those conditions.

Key benefits:

  • Reactive coordination - Commands respond to state changes automatically
  • Declarative dependencies - Chain commands together without manual orchestration
  • Automatic UI updates - canRun reflects restrictions instantly
  • Centralized logic - No scattered if checks throughout your code

Overview

Commands can be conditionally enabled or disabled using the restriction parameter, which accepts a ValueListenable<bool>. This allows restrictions to change dynamically after the command is created - the command automatically responds to state changes.

Key concept: When the restriction's current value is true, the command is disabled

dart
Command.createAsyncNoParam<List<Todo>>(
  () => api.fetchTodos(),
  initialValue: [],
  restriction: isLoggedIn.map((logged) => !logged), // disabled when NOT logged in
);

Any change of restriction is reflected in the canRun property of the command with this formula: canRun = !isRunning && !restriction

UI Integration

Because canRun automatically reflects both execution state and restrictions, it's ideal for enabling/disabling UI elements. Just watch canRun and your buttons automatically enable/disable as conditions change - no manual state tracking needed.

Basic Restriction with ValueNotifier

The most common pattern is restricting based on application state:

dart
class DataWidget extends StatelessWidget {
  final manager = DataManager();

  DataWidget({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        // Observe login state
        ValueListenableBuilder<bool>(
          valueListenable: manager.isLoggedIn,
          builder: (context, isLoggedIn, _) {
            return Column(
              children: [
                Text(
                  isLoggedIn ? 'Logged In' : 'Not Logged In',
                  style: const TextStyle(fontSize: 18),
                ),
                const SizedBox(height: 8),
                ElevatedButton(
                  onPressed: () => manager.isLoggedIn.value = !isLoggedIn,
                  child: Text(isLoggedIn ? 'Log Out' : 'Log In'),
                ),
              ],
            );
          },
        ),
        const SizedBox(height: 16),
        // Observe canRun - automatically reflects restriction + isRunning
        ValueListenableBuilder<bool>(
          valueListenable: manager.loadDataCommand.canRun,
          builder: (context, canRun, _) {
            return ElevatedButton(
              onPressed: canRun ? manager.loadDataCommand.run : null,
              child: const Text('Load Data'),
            );
          },
        ),
        const SizedBox(height: 16),
        // Observe results
        ValueListenableBuilder<CommandResult<void, List<Todo>>>(
          valueListenable: manager.loadDataCommand.results,
          builder: (context, result, _) {
            if (result.isRunning) {
              return const CircularProgressIndicator();
            }

            if (result.hasError) {
              return Text(
                'Error: ${result.error}',
                style: const TextStyle(color: Colors.red),
              );
            }

            if (result.data?.isEmpty ?? true) {
              return const Text('No data loaded');
            }

            return Text('Loaded ${result.data?.length ?? 0} todos');
          },
        ),
      ],
    );
  }
}

How it works:

  1. Create a ValueNotifier<bool> to track state (isLoggedIn)
  2. Map it to restriction logic: !logged means "restrict when NOT logged in"
  3. Command automatically updates canRun property
  4. Use watchValue() to observe canRun in your widget
  5. Button automatically disables when canRun is false

Important: The restriction parameter expects ValueListenable<bool> where true means "disabled". Because it's a ValueListenable, the restriction can change at any time - the command automatically reacts and updates canRun accordingly.

Chaining Commands via isRunningSync

Prevent commands from running while other commands execute:

dart
class DataManager {
  final api = ApiClient();

  // First command: load initial data
  late final loadCommand = Command.createAsyncNoParam<List<Todo>>(
    () => api.fetchTodos(),
    initialValue: [],
  );

  // Second command: can't save while loading
  late final saveCommand = Command.createAsyncNoResult<Todo>(
    (todo) async {
      await simulateDelay();
      // Save logic here
    },
    // Restrict based on first command's running state
    restriction: loadCommand.isRunningSync,
  );

  // Third command: can't update while loading OR saving
  late final updateCommand = Command.createAsyncNoResult<Todo>(
    (todo) async {
      await simulateDelay(500);
      // Update logic here
    },
    // Combine multiple restrictions: disabled if EITHER command is running
    restriction: loadCommand.isRunningSync.combineLatest(
      saveCommand.isRunningSync,
      (isLoading, isSaving) => isLoading || isSaving,
    ),
  );
}

class ChainedCommandsWidget extends WatchingWidget {
  const ChainedCommandsWidget({super.key});

  @override
  Widget build(BuildContext context) {
    // Watch all canRun states
    final canRunLoad = watchValue((DataManager m) => m.loadCommand.canRun);
    final canRunSave = watchValue((DataManager m) => m.saveCommand.canRun);
    final canRunUpdate = watchValue((DataManager m) => m.updateCommand.canRun);
    final todos = watchValue((DataManager m) => m.loadCommand);

    return Padding(
      padding: EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          Text('Command Chaining Example',
              style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
          SizedBox(height: 16),

          // Load button
          ElevatedButton(
            onPressed: canRunLoad ? di<DataManager>().loadCommand.run : null,
            child: Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                if (!canRunLoad) ...[
                  SizedBox(
                    width: 16,
                    height: 16,
                    child: CircularProgressIndicator(
                        strokeWidth: 2, color: Colors.white),
                  ),
                  SizedBox(width: 8),
                ],
                Text('Load Data'),
              ],
            ),
          ),
          SizedBox(height: 8),

          // Save button - disabled while loading
          ElevatedButton(
            onPressed: canRunSave
                ? () =>
                    di<DataManager>().saveCommand(Todo('1', 'Test Todo', false))
                : null,
            child:
                Text(canRunSave ? 'Save Todo' : 'Save (blocked while loading)'),
          ),
          SizedBox(height: 8),

          // Update button - disabled while loading OR saving
          ElevatedButton(
            onPressed: canRunUpdate
                ? () => di<DataManager>()
                    .updateCommand(Todo('2', 'Updated Todo', false))
                : null,
            child: Text(canRunUpdate
                ? 'Update Todo'
                : 'Update (blocked while loading/saving)'),
          ),
          SizedBox(height: 16),

          // Status display
          Text('Loaded ${todos.length} todos'),
        ],
      ),
    );
  }
}

How it works:

  1. saveCommand uses loadCommand.isRunningSync as restriction
  2. While loading, saveCommand cannot run
  3. updateCommand uses combineLatest to combine both running states
  4. Update is disabled if EITHER load OR save is running
  5. Demonstrates combining multiple restrictions with listen_it operators

Why isRunningSync?

  • isRunning updates asynchronously to avoid race conditions in UI rebuilding
  • isRunningSync updates immediately
  • Prevents race conditions in restrictions
  • Use isRunning for UI, isRunningSync for restrictions

canRun Property

canRun automatically combines running state and restrictions:

dart
class MyWidget extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    final canRun = watchValue((MyManager m) => m.command.canRun);

    return ElevatedButton(
      onPressed: canRun ? di<MyManager>().command.run : null,
      child: Text('Execute'),
    );
  }
}

canRun is true when:

  • Command is NOT running (!isRunning)
  • AND restriction is false (!restriction)

This is more convenient than manually checking both conditions.

Restriction Patterns

Authentication-Based Restriction

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

late final dataCommand = Command.createAsyncNoParam<Data>(
  () => api.fetchSecureData(),
  initialValue: Data.empty(),
  restriction: isAuthenticated.map((auth) => !auth), // disabled when not authenticated
);

Validation-Based Restriction

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

late final submitCommand = Command.createAsync<FormData, void>(
  (data) => api.submit(data),
  restriction: formValid.map((valid) => !valid), // disabled when invalid
);

Multiple Conditions

Use ValueListenable operators to combine restrictions:

dart
final isOnline = ValueNotifier<bool>(true);
final hasPermission = ValueNotifier<bool>(false);

late final syncCommand = Command.createAsyncNoParam<void>(
  () => api.sync(),
  // Disabled when offline OR no permission
  restriction: isOnline.combineLatest(
    hasPermission,
    (online, permission) => !online || !permission,
  ),
);

Temporary Restrictions

Restrict commands during specific operations:

dart
class DataManager {
  final isSyncing = ValueNotifier<bool>(false);

  late final deleteCommand = Command.createAsync<String, void>(
    (id) => api.delete(id),
    // Can't delete while syncing
    restriction: isSyncing,
  );

  Future<void> sync() async {
    isSyncing.value = true;
    try {
      await api.syncAll();
    } finally {
      isSyncing.value = false;
    }
  }
}

Even More Elegant

If you implement sync() as a command too, you can use its isRunningSync directly as the restriction - no need to manually manage isSyncing. See the Chaining Commands example above.

Alternative Actions with ifRestrictedRunInstead

When a command is restricted, you may want to take an alternative action instead of silently doing nothing. The ifRestrictedRunInstead parameter provides a fallback handler that executes when the command is restricted.

Common use cases:

  • ✅ Show login dialog when user needs authentication
  • ✅ Display error messages explaining why action can't be performed
  • ✅ Log analytics events for restricted attempts
  • ✅ Navigate to a different screen or show a modal
dart
class DataService {
  final isAuthenticated = ValueNotifier<bool>(false);

  late final fetchDataCommand = Command.createAsync<String, List<String>>(
    (query) async {
      // Fetch data from API
      final api = getIt<ApiClient>();
      return await api.searchData(query);
    },
    initialValue: [], // initial value
    restriction:
        isAuthenticated.map((auth) => !auth), // disabled when not authenticated
    ifRestrictedRunInstead: (query) {
      // Called when command is restricted (not authenticated)
      // Show login prompt instead of executing
      debugPrint('Please log in to search for: $query');
      showLoginDialog();
    },
  );

  void showLoginDialog() {
    // In a real app, this would show a dialog
    debugPrint('Showing login dialog...');
  }
}

How it works:

  1. The handler receives the parameter that was passed to the command
  2. Called only when restriction is true (command is disabled)
  3. The original wrapped function is NOT executed
  4. Use it for user feedback or alternative flows

Access to Parameters

The ifRestrictedRunInstead handler receives the same parameter that would have been passed to the wrapped function. This allows you to provide context-aware feedback (e.g., "Please log in to search for '{query}'").

NoParam commands: For NoParam commands (createAsyncNoParam, createSyncNoParam), the ifRestrictedRunInstead handler has no parameter: void Function() instead of RunInsteadHandler<TParam>.

Restriction vs Manual Checks

❌️ Without restrictions (manual checks):

dart
void handleSave() {
  if (!isLoggedIn.value) return; // Manual check
  if (command.isRunning.value) return; // Manual check
  command.run();
}

✅ With restrictions (automatic):

dart
late final command = Command.createAsync<Data, void>(
  (data) => api.save(data),
  restriction: isLoggedIn.map((logged) => !logged),
);

// UI automatically disables when restricted
class SaveWidget extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    final canRun = watchValue((MyManager m) => m.command.canRun);

    return ElevatedButton(
      onPressed: canRun ? () => di<MyManager>().command(data) : null,
      child: Text('Save'),
    );
  }
}

Benefits:

  • UI automatically reflects state
  • No manual checks needed
  • Centralized logic
  • Reactive to state changes

Common Mistakes

❌️ Inverting the restriction logic

dart
// WRONG: restriction expects true = disabled
restriction: isLoggedIn, // disabled when logged in (backwards!)
dart
// CORRECT: negate the condition
restriction: isLoggedIn.map((logged) => !logged), // disabled when NOT logged in

❌️ Using isRunning for restrictions

dart
// WRONG: async update can cause race conditions
restriction: otherCommand.isRunning,
dart
// CORRECT: use synchronous version
restriction: otherCommand.isRunningSync,

❌️ Forgetting to dispose restriction sources

dart
class Manager {
  final customRestriction = ValueNotifier<bool>(false);

  late final command = Command.createAsync<Data, void>(
    (data) => api.save(data),
    restriction: customRestriction,
  );

  void dispose() {
    command.dispose();
    customRestriction.dispose(); // Don't forget this!
  }
}

See Also

Released under the MIT License.